Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/collections-stage3-studio.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@cloudhal/cli": minor
"@cloudhal/api": minor
---

Collections stage 3, step 5 (RFC-0042 §7 / ADR-0044): Studio.

- **Collection settings** get three cards, each saved on its own: **Listing fields** (up to four fields, ordered; a field content lists still use names those pages), **Expiry** (a date or date-and-time field, "Leave lists" or "Hide the page" explained in words, a grace in minutes, and how many published entries are already past it; a site Worker that does not apply expiry yet says to deploy first) and **Structured data** (Event, Job posting, Product or Recipe; every property offers only the sources that fit it, required ones are marked, problems show while editing, and a chosen entry's JSON-LD is previewed with a link to Google's Rich Results Test).
- **Create listing page** makes a draft page at the collection's address with a content list of it (upcoming first for a collection with a date listing field and an expiry) and opens it; when a page already lives there, the action opens that page.
- **Collection lists** show listing fields as columns, filter by them and sort by number and date fields. Filters and sort live in the URL and ask the API by field, one page at a time.
- **Page editor**: content list sections get conditions and an order by the collection's listing fields; refused conditions show on their row.
- Words for `invalid_query`, `structured_data_invalid`, `invalid_expiry`, `list_field_in_use` and `gates_unsupported`, the `preview_structured_data` tool and the expiry auto-publish check. Translations are AI drafts awaiting review.
- `@cloudhal/api`: new read-only `GET /api/sites/:siteId/entries/:entryId/structured-data` (`content:read`), the REST twin of MCP's `preview_structured_data`: the preset document an entry's page emits, its issues and the preset's warnings.
11 changes: 10 additions & 1 deletion apps/studio/bundle-budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ export const BUNDLE_BUDGET = {
* `loadLocale`) and the `menus` words moved to a lazy namespace, so the budget came down from
* 455 kB. 387.6 kB on 2026-10-22 (was 419.9 kB) once the bell's items, the elevation and
* agent-mode UI and the taxonomy model left the shell (`LAZY_ONLY`); the budget stays at
* 420 kB as headroom for shell features.
* 420 kB as headroom for shell features. 390.0 kB on 2026-10-23 with collections stage 3 (RFC-0042):
* its screens are lazy; the shell gained the router's list-field search check, five error
* sentences and the expiry auto-check words, and Kumo's shared chunks split once more (+0.8 kB).
*/
initialGzipBytes: 420_000,
};
Expand Down Expand Up @@ -83,6 +85,13 @@ export const LAZY_ONLY: readonly RegExp[] = [
/[\\/]packages[\\/]core-ts[\\/]src[\\/]fields[\\/]/,
/[\\/]apps[\\/]studio[\\/]src[\\/]components[\\/]fields[\\/]/,
/[\\/]apps[\\/]studio[\\/]src[\\/]lib[\\/](fields|field-form)\.ts$/,
// Collections stage 3 (RFC-0042 §7): the listing, expiry and structured-data cards, the list's
// field columns and filters, "Create listing page", the content list conditions, their model and
// client, and the preset table load with their screens (the shell keeps `listing-search.ts`).
/[\\/]packages[\\/]core-ts[\\/]src[\\/]structured-data[\\/]/,
/[\\/]apps[\\/]studio[\\/]src[\\/]components[\\/]listings[\\/]/,
/[\\/]apps[\\/]studio[\\/]src[\\/]components[\\/]useCollectionEntries\.ts$/,
/[\\/]apps[\\/]studio[\\/]src[\\/]lib[\\/](listings|listing-query|api[\\/]listings)\.ts$/,
// Lazy message namespaces: their words load with the screens that show them.
/[\\/]apps[\\/]studio[\\/]src[\\/]i18n[\\/]lazy[\\/]/,
// Core catalogs other than English load with their language (`loadLocale`), never with English.
Expand Down
5 changes: 3 additions & 2 deletions apps/studio/src/components/ErrorBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Banner } from "@cloudhal/ui";
import { ArrowClockwise, WarningCircle } from "@phosphor-icons/react";
import { useT } from "../i18n/index.tsx";
import { ApiRequestError } from "../lib/api.ts";
import { apiErrorText } from "../lib/api-errors.ts";
import { apiErrorText, isWordedCode } from "../lib/api-errors.ts";

/**
* Page/section error (DESIGN.md §5): plain-language message (`apiErrorText`), optional retry
Expand All @@ -24,7 +24,8 @@ export function ErrorBanner({
const t = useT();
if (!error) return null;
const message =
error instanceof ApiRequestError && error.status === 409
// A 409 is someone else's change, unless its code has words of its own (`list_field_in_use`).
error instanceof ApiRequestError && error.status === 409 && !isWordedCode(error.body?.error)
? t("errors.conflict")
: apiErrorText(t, error);
return (
Expand Down
341 changes: 341 additions & 0 deletions apps/studio/src/components/listings/ContentListQuery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,341 @@
import type {
ContentListSection,
FieldCondition,
FieldDefinition,
FieldOrder,
} from "@cloudhal/core-contracts";
import {
FIELD_QUERY_LIMITS,
FIELD_QUERY_OPS,
type FieldQueryOp,
fieldLabel,
NOW,
optionLabel,
} from "@cloudhal/core-ts/fields";
import { Button, Checkbox, Input, Link, Select, Text } from "@cloudhal/ui";
import { Plus, Trash } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useI18n } from "../../i18n/index.tsx";
import { api } from "../../lib/api.ts";
import { listFieldDefs, sortable, timeKind } from "../../lib/listings.ts";

const NONE = "__none";

/** A condition's defaults when its field or operator changes. */
function freshCondition(def: FieldDefinition, op?: FieldQueryOp): FieldCondition {
const chosen = op ?? FIELD_QUERY_OPS[def.type][0] ?? "set";
if (chosen === "set" || chosen === "unset") return { field: def.key, op: chosen };
if (def.type === "date" || def.type === "datetime")
return { field: def.key, op: chosen, value: NOW };
if (def.type === "boolean") return { field: def.key, op: chosen, value: "true" };
if (def.type === "select") {
const first = def.options[0]?.value ?? "";
return chosen === "in"
? { field: def.key, op: chosen, values: [first] }
: { field: def.key, op: chosen, value: first };
}
return { field: def.key, op: chosen, value: "" };
}

/** The section with `where` / `orderBy` replaced (empty ones left out, as the contract asks). */
function withQuery(
section: ContentListSection,
where: FieldCondition[],
orderBy: FieldOrder | undefined,
): ContentListSection {
const { where: _w, orderBy: _o, ...rest } = section;
return {
...rest,
...(where.length ? { where: where as ContentListSection["where"] } : {}),
...(orderBy ? { orderBy } : {}),
};
}

/**
* The page editor's content list conditions (RFC-0042 §4): "only entries where" rows over the
* listed collection's listing fields — each with the operators its type allows and a value control
* for that type (`now` or a date for dates, the options of a choice, yes / no) — and an order by a
* number or date field. The save's `invalid_query` issues show under their row.
*/
export function ContentListQuery({
siteId,
section,
onChange,
issues,
}: {
siteId: string;
section: ContentListSection;
onChange: (section: ContentListSection) => void;
/** The last save's `invalid_query` issues of this section (`where/0/value`, `orderBy/field`). */
issues: readonly { path: string; message: string }[];
}) {
const { t, locale } = useI18n("listings");
const c = "listings.contentList";
const site = useQuery({ queryKey: ["site", siteId], queryFn: () => api.sites.get(siteId) });
const settings = site.data?.collections?.[section.collection];
const defs = listFieldDefs(settings);
const where = section.where ?? [];
const label = (d: FieldDefinition) => fieldLabel(d, locale, site.data?.defaultLocale ?? "en");
const issueAt = (prefix: string) =>
issues.filter((i) => i.path === prefix || i.path.startsWith(`${prefix}/`));
if (!site.data) return null;
if (defs.length === 0)
return (
<Text variant="secondary" size="sm">
{t(`${c}.noListFields`)}{" "}
<Link href={`/sites/${siteId}/collections/${section.collection}/settings#listing-fields`}>
{t(`${c}.settingsLink`)}
</Link>
</Text>
);
const setWhere = (next: FieldCondition[]) => onChange(withQuery(section, next, section.orderBy));
const sortDefs = defs.filter(sortable);
const orderDef = defs.find((d) => d.key === section.orderBy?.field);
return (
<div className="grid gap-4" data-testid="content-list-query">
<div className="grid gap-3">
<Text bold as="span">
{t(`${c}.where`)}
</Text>
{where.map((cond, i) => (
<ConditionRow
key={`${i}:${cond.field}`}
cond={cond}
defs={defs}
label={label}
defaultLocale={site.data?.defaultLocale ?? "en"}
errors={issueAt(`where/${i}`).map((x) => x.message)}
onChange={(next) => setWhere(where.map((w, j) => (j === i ? next : w)))}
onRemove={() => setWhere(where.filter((_, j) => j !== i))}
/>
))}
<div>
<Button
variant="secondary"
size="sm"
icon={<Plus />}
disabled={where.length >= FIELD_QUERY_LIMITS.conditions}
onClick={() => defs[0] && setWhere([...where, freshCondition(defs[0])])}
data-testid="add-condition"
>
{t(`${c}.add`)}
</Button>
</div>
</div>
{sortDefs.length > 0 && (
<div className="grid items-start gap-x-4 gap-y-5 sm:grid-cols-2">
<Select
className="w-full"
label={t(`${c}.orderBy`)}
value={section.orderBy?.field ?? NONE}
onValueChange={(v) =>
onChange(
withQuery(
section,
where,
v === NONE
? undefined
: { field: v as string, direction: section.orderBy?.direction ?? "asc" },
),
)
}
items={{
[NONE]: t(`${c}.orderNone`),
...Object.fromEntries(sortDefs.map((d) => [d.key, label(d)])),
}}
error={issueAt("orderBy")[0]?.message}
data-testid="order-by-field"
/>
{section.orderBy && (
<Select
className="w-full"
label={t(`${c}.direction`)}
value={section.orderBy.direction}
onValueChange={(v) =>
section.orderBy &&
onChange(
withQuery(section, where, {
field: section.orderBy.field,
direction: v === "desc" ? "desc" : "asc",
}),
)
}
items={{
asc: t(`${c}.asc`, { kind: timeKind(orderDef) }),
desc: t(`${c}.desc`, { kind: timeKind(orderDef) }),
}}
data-testid="order-by-direction"
/>
)}
</div>
)}
</div>
);
}

/** One condition: field, operator (only its type's), the value control, remove. */
function ConditionRow({
cond,
defs,
label,
defaultLocale,
errors,
onChange,
onRemove,
}: {
cond: FieldCondition;
defs: readonly FieldDefinition[];
label: (d: FieldDefinition) => string;
defaultLocale: string;
errors: readonly string[];
onChange: (cond: FieldCondition) => void;
onRemove: () => void;
}) {
const { t } = useI18n("listings");
const c = "listings.contentList";
const def = defs.find((d) => d.key === cond.field);
const ops = def ? FIELD_QUERY_OPS[def.type] : [];
return (
<div className="grid gap-2" data-condition={cond.field}>
<div className="grid items-end gap-3 sm:grid-cols-[1fr_1fr_1fr_auto]">
<Select
className="w-full"
label={t(`${c}.field`)}
value={cond.field}
onValueChange={(v) => {
const next = defs.find((d) => d.key === v);
if (next) onChange(freshCondition(next));
}}
items={Object.fromEntries(defs.map((d) => [d.key, label(d)]))}
/>
<Select
className="w-full"
label={t(`${c}.op`)}
value={cond.op}
onValueChange={(v) => def && onChange(freshCondition(def, v as FieldQueryOp))}
items={Object.fromEntries(
ops.map((op) => [op, t(`${c}.ops.${op}`, { kind: timeKind(def) })]),
)}
data-testid={`condition-op-${cond.field}`}
/>
{def && (
<ValueControl cond={cond} def={def} defaultLocale={defaultLocale} onChange={onChange} />
)}
<Button
variant="ghost"
shape="square"
icon={<Trash />}
aria-label={t(`${c}.remove`)}
title={t(`${c}.remove`)}
onClick={onRemove}
/>
</div>
{errors.map((e) => (
<Text key={e} variant="error" size="sm">
{e}
</Text>
))}
</div>
);
}

/** The value of a condition, by its field's type; none for "has a value" / "is empty". */
function ValueControl({
cond,
def,
defaultLocale,
onChange,
}: {
cond: FieldCondition;
def: FieldDefinition;
defaultLocale: string;
onChange: (cond: FieldCondition) => void;
}) {
const { t, locale } = useI18n("listings");
const c = "listings.contentList";
const label = t(`${c}.value`);
if (cond.op === "set" || cond.op === "unset") return <span />;
const value = cond.value ?? "";
const set = (v: string) => onChange({ field: cond.field, op: cond.op, value: v });
if (def.type === "select") {
const items = Object.fromEntries(
def.options.map((o) => [o.value, optionLabel(def, o.value, locale, defaultLocale)]),
);
if (cond.op === "in")
return (
<Checkbox.Group
legend={label}
value={[...(cond.values ?? [])]}
allValues={def.options.map((o) => o.value)}
onValueChange={(next: string[]) => {
const values = def.options
.map((o) => o.value)
.filter((v) => next.includes(v))
.slice(0, FIELD_QUERY_LIMITS.values);
onChange(
values.length
? { field: cond.field, op: "in", values: values as FieldCondition["values"] }
: { field: cond.field, op: "in" },
);
}}
>
{def.options.map((o) => (
<Checkbox.Item key={o.value} value={o.value} label={items[o.value] ?? o.value} />
))}
</Checkbox.Group>
);
return (
<Select
className="w-full"
label={label}
value={value}
onValueChange={(v) => set(v as string)}
items={items}
/>
);
}
if (def.type === "boolean")
return (
<Select
className="w-full"
label={label}
value={value}
onValueChange={(v) => set(v as string)}
items={{ true: t(`${c}.yes`), false: t(`${c}.no`) }}
/>
);
if (def.type === "date" || def.type === "datetime")
return (
<div className="grid gap-2">
<Select
className="w-full"
label={label}
value={value === NOW ? NOW : "date"}
onValueChange={(v) => set(v === NOW ? NOW : "")}
items={{ [NOW]: t(`${c}.now`), date: t(`${c}.aDate`) }}
data-testid={`condition-when-${cond.field}`}
/>
{value !== NOW && (
<Input
className="w-full"
aria-label={label}
type={def.type === "date" ? "date" : "text"}
value={value}
onValueChange={set}
description={def.type === "date" ? t(`${c}.dateHint`) : t(`${c}.instantHint`)}
/>
)}
</div>
);
return (
<Input
className="w-full"
label={label}
type={def.type === "number" ? "number" : "text"}
value={value}
maxLength={100}
onValueChange={set}
data-testid={`condition-value-${cond.field}`}
/>
);
}
Loading
Loading