diff --git a/.changeset/collections-stage3-studio.md b/.changeset/collections-stage3-studio.md new file mode 100644 index 00000000..1837238a --- /dev/null +++ b/.changeset/collections-stage3-studio.md @@ -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. diff --git a/apps/studio/bundle-budget.ts b/apps/studio/bundle-budget.ts index d3cc669c..70dcaa4c 100644 --- a/apps/studio/bundle-budget.ts +++ b/apps/studio/bundle-budget.ts @@ -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, }; @@ -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. diff --git a/apps/studio/src/components/ErrorBanner.tsx b/apps/studio/src/components/ErrorBanner.tsx index 24af1322..0ada606f 100644 --- a/apps/studio/src/components/ErrorBanner.tsx +++ b/apps/studio/src/components/ErrorBanner.tsx @@ -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 @@ -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 ( diff --git a/apps/studio/src/components/listings/ContentListQuery.tsx b/apps/studio/src/components/listings/ContentListQuery.tsx new file mode 100644 index 00000000..2d6db38e --- /dev/null +++ b/apps/studio/src/components/listings/ContentListQuery.tsx @@ -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 ( + + {t(`${c}.noListFields`)}{" "} + + {t(`${c}.settingsLink`)} + + + ); + 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 ( +
+
+ + {t(`${c}.where`)} + + {where.map((cond, i) => ( + x.message)} + onChange={(next) => setWhere(where.map((w, j) => (j === i ? next : w)))} + onRemove={() => setWhere(where.filter((_, j) => j !== i))} + /> + ))} +
+ +
+
+ {sortDefs.length > 0 && ( +
+ + 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" + /> + )} +
+ )} +
+ ); +} + +/** 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 ( +
+
+ 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 && ( + + )} +
+ {errors.map((e) => ( + + {e} + + ))} +
+ ); +} + +/** 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 ; + 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 ( + 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) => ( + + ))} + + ); + return ( + set(v as string)} + items={{ true: t(`${c}.yes`), false: t(`${c}.no`) }} + /> + ); + if (def.type === "date" || def.type === "datetime") + return ( +
+ + )} +
+ ); + return ( + + ); +} diff --git a/apps/studio/src/components/listings/CreateListingPage.tsx b/apps/studio/src/components/listings/CreateListingPage.tsx new file mode 100644 index 00000000..3f3c4221 --- /dev/null +++ b/apps/studio/src/components/listings/CreateListingPage.tsx @@ -0,0 +1,83 @@ +import type { Site } from "@cloudhal/core-contracts"; +import { collectionPermalink, permalinkPrefix } from "@cloudhal/core-ts/permalinks"; +import { Button, LinkButton } from "@cloudhal/ui"; +import { ListBullets } from "@phosphor-icons/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { useI18n } from "../../i18n/index.tsx"; +import { api } from "../../lib/api.ts"; +import { apiErrorText } from "../../lib/api-errors.ts"; +import { listingSection, listingTitle } from "../../lib/listings.ts"; +import { sectionId } from "../page-editor/sections.ts"; +import { useToast } from "../Toast.ts"; +import { useCan } from "../useMe.ts"; + +/** + * RFC-0042 §5 "Create listing page": a draft page at the collection's address (`/events`), titled + * with its plural name, holding one `contentList` section of the collection (upcoming first when it + * has a date list field and an expiry). It goes through the ordinary page write, then opens in the + * page editor. When a page already lives at that address, the action opens it instead. + */ +export function CreateListingPage({ site, collection }: { site: Site; collection: string }) { + const { t } = useI18n("listings"); + const qc = useQueryClient(); + const navigate = useNavigate(); + const toast = useToast(); + const canWrite = useCan("pages.write"); + const pages = useQuery({ + queryKey: ["pages", site.id], + queryFn: () => api.pages.list(site.id), + enabled: canWrite, + }); + const path = permalinkPrefix(collectionPermalink(site, collection)); + const existing = pages.data?.find((p) => p.path === path && p.locale === site.defaultLocale); + const create = useMutation({ + mutationFn: () => + api.pages.create(site.id, { + path, + title: listingTitle(site, collection), + locale: site.defaultLocale, + status: "draft", + sections: [listingSection(sectionId(), collection, site.collections?.[collection] ?? {})], + }), + onSuccess: async ({ page, site: next }) => { + qc.setQueryData(["site", site.id], next); + await qc.invalidateQueries({ queryKey: ["pages", site.id] }); + toast.success(t("listings.createPage.created"), page.path); + await navigate({ + to: "/sites/$siteId/pages/$pageId", + params: { siteId: site.id, pageId: page.id }, + }); + }, + onError: (error) => { + toast.error(t("errors.title"), apiErrorText(t, error)); + // Someone may have made a page at that address meanwhile: the action then opens it. + void pages.refetch(); + }, + }); + if (!canWrite) return null; + if (existing) + return ( + + {t("listings.createPage.open")} + + ); + return ( + + ); +} diff --git a/apps/studio/src/components/listings/ExpirySection.tsx b/apps/studio/src/components/listings/ExpirySection.tsx new file mode 100644 index 00000000..6d368b5e --- /dev/null +++ b/apps/studio/src/components/listings/ExpirySection.tsx @@ -0,0 +1,199 @@ +import type { CollectionExpiry, CollectionSettings, Site } from "@cloudhal/core-contracts"; +import { fieldGateInstant, fieldLabel } from "@cloudhal/core-ts/fields"; +import { Banner, Button, Input, Radio, Select, Text } from "@cloudhal/ui"; +import { CloudArrowUp } from "@phosphor-icons/react"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; +import { useI18n } from "../../i18n/index.tsx"; +import { api, errorCode } from "../../lib/api.ts"; +import { dateFields, liveFields } from "../../lib/listings.ts"; +import { ErrorBanner } from "../ErrorBanner.tsx"; +import { SettingsSection } from "../Settings.tsx"; +import { SwitchField } from "../TaxonomySettingsDialog.tsx"; +import { useNow } from "../useNow.ts"; +import { LISTING_SECTIONS } from "./sections.ts"; +import { same, useSaveSetting } from "./useSaveSetting.ts"; + +/** The contract's longest grace: one week. */ +const MAX_GRACE = 10_080; + +interface Form { + on: boolean; + field: string; + action: CollectionExpiry["action"]; + grace: string; +} + +const formOf = (expiry: CollectionExpiry | undefined, first: string): Form => ({ + on: expiry !== undefined, + field: expiry?.field ?? first, + action: expiry?.action ?? "unlist", + grace: expiry?.afterMinutes ? String(expiry.afterMinutes) : "", +}); + +/** The rule the form describes; `undefined` when off (or without a field). */ +export function expiryOf(form: Form): CollectionExpiry | undefined { + if (!form.on || !form.field) return undefined; + const grace = Math.min(MAX_GRACE, Math.max(0, Math.trunc(Number(form.grace) || 0))); + return { field: form.field, action: form.action, ...(grace > 0 ? { afterMinutes: grace } : {}) }; +} + +/** Published entries of the collection already past the rule (counted in Studio, RFC-0042 §7). */ +function usePastCount(site: Site, collection: string, rule: CollectionExpiry | undefined) { + const now = useNow(60_000); + const published = useQuery({ + queryKey: ["entries", site.id, collection, "published"], + queryFn: () => api.entries.list(site.id, { collection, status: ["published"] }), + enabled: rule !== undefined, + }); + return useMemo(() => { + const def = + rule && liveFields(site.collections?.[collection] ?? {}).find((d) => d.key === rule.field); + if (!def || !published.data) return undefined; + return published.data.filter((e) => { + const at = fieldGateInstant(e.fields?.[def.key], def, site, rule?.afterMinutes ?? 0); + return at !== undefined && at <= now; + }).length; + }, [rule, published.data, site, collection, now]); +} + +/** + * A collection › Settings › Expiry (RFC-0042 §3, §7): the date field entries end on (dates and + * dates with times only), what happens then (leave lists, or hide the page) in words, the grace, + * and how many published entries are already past it. A site Worker that does not apply derived + * gates refuses the rule (`gates_unsupported`): the card says to deploy first. + */ +export function ExpirySection({ + site, + collection, + settings, + readOnlyReason, +}: { + site: Site; + collection: string; + settings: CollectionSettings; + readOnlyReason: string | undefined; +}) { + const { t, locale } = useI18n("listings"); + const dates = dateFields(liveFields(settings)); + const first = dates[0]?.key ?? ""; + const saved = settings.expiry; + const [form, setForm] = useState(() => formOf(saved, first)); + const savedKey = JSON.stringify(saved ?? null); + useEffect(() => setForm(formOf(saved, first)), [savedKey, first]); + const save = useSaveSetting(site, collection, "expiry", t("listings.expiry.saved")); + const rule = expiryOf(form); + const past = usePastCount(site, collection, rule); + const dirty = !same(rule, saved); + const canEdit = readOnlyReason === undefined; + const set = (k: K, v: Form[K]) => setForm((f) => ({ ...f, [k]: v })); + const unsupported = errorCode(save.error) === "gates_unsupported"; + return ( + + {dirty && ( + + )} + + + } + > +
+ {unsupported ? ( + } + title={t("errors.title")} + description={t("listings.expiry.gatesUnsupported")} + data-testid="expiry-gates-unsupported" + /> + ) : ( + + )} + {dates.length === 0 && !saved ? ( + {t("listings.expiry.noDateFields")} + ) : ( + <> + set("on", v)} + disabled={!canEdit} + testId="expiry-on" + /> + {form.on && ( + <> + set("grace", v)} + required={false} + data-testid="expiry-grace" + /> + {past !== undefined && ( + + {t("listings.expiry.past", { count: past })} + + )} + + )} + + )} +
+
+ ); +} diff --git a/apps/studio/src/components/listings/ListFieldColumns.tsx b/apps/studio/src/components/listings/ListFieldColumns.tsx new file mode 100644 index 00000000..df08f25f --- /dev/null +++ b/apps/studio/src/components/listings/ListFieldColumns.tsx @@ -0,0 +1,160 @@ +/** + * RFC-0042 §4 "Studio": a collection list's listing-field columns, filters and sort, loaded only + * for a collection with `listFields` (`LAZY_ONLY`). Values show as readers would read them + * (`formatFieldValue` in the Studio language); filters and sort live in the URL, and the list asks + * the API with them (`useCollectionEntries`). + */ +import type { ContentEntry, FieldDefinition, FieldOrder, Site } from "@cloudhal/core-contracts"; +import { fieldLabel, formatFieldValue, optionLabel } from "@cloudhal/core-ts/fields"; +import { Button, Select, Table, Text, Toolbar } from "@cloudhal/ui"; +import { ArrowDown, ArrowsDownUp, ArrowUp } from "@phosphor-icons/react"; +import { useI18n } from "../../i18n/index.tsx"; +import { listFieldDefs, sortable } from "../../lib/listings.ts"; + +const ANY = "__any"; + +/** The filter tokens a field type offers (`lib/listing-query.ts` turns them into conditions). */ +function tokensOf(def: FieldDefinition): string[] { + switch (def.type) { + case "date": + case "datetime": + return ["upcoming", "past", "set", "unset"]; + case "boolean": + return ["yes", "no"]; + case "select": + return [...def.options.map((o) => `is:${o.value}`), "unset"]; + default: + return ["set", "unset"]; + } +} + +function useDefs(site: Site, collection: string) { + const { locale } = useI18n(); + const defs = listFieldDefs(site.collections?.[collection]); + const label = (def: FieldDefinition) => fieldLabel(def, locale, site.defaultLocale); + return { defs, label, locale }; +} + +/** One `Select` per listing field: "Starts: upcoming", "Format: Workshop", "Seats: empty". */ +export function ListFieldFilters({ + site, + collection, + filters, + onFilter, +}: { + site: Site; + collection: string; + filters: Readonly>; + onFilter: (field: string, token: string | undefined) => void; +}) { + const { t } = useI18n("listings"); + const { defs, label, locale } = useDefs(site, collection); + const words = (def: FieldDefinition, token: string) => { + const l = label(def); + if (token.startsWith("is:")) + return t("listings.filters.is", { + label: l, + value: optionLabel(def, token.slice(3), locale, site.defaultLocale), + }); + return t(`listings.filters.${token as "set"}`, { label: l }); + }; + return ( + + {defs.map((def) => ( + = MAX ? t("listings.listFields.full") : undefined} + placeholder={t("listings.listFields.choose")} + value={null as string | null} + onValueChange={(v) => v && setDraft([...draft, v as string])} + items={Object.fromEntries(free.map((d) => [d.key, label(d.key)]))} + disabled={!canEdit || draft.length >= MAX || free.length === 0} + data-testid="add-list-field" + /> + )} + + + ); +} + +/** One chosen field: label, key, type and "Sortable"; up, down and remove. */ +function ListFieldRow({ + fieldKey, + def, + label, + first, + last, + canEdit, + onMove, + onRemove, +}: { + fieldKey: string; + def: FieldDefinition | undefined; + label: string; + first: boolean; + last: boolean; + canEdit: boolean; + onMove: (by: -1 | 1) => void; + onRemove: () => void; +}) { + const { t } = useI18n("listings", "fields"); + const iconButton = (text: string, icon: ReactNode, onClick: () => void, off: boolean) => ( + - } - /> - ) : ( - } - title={header.empty} - description={t("articles.emptyDescription")} - contents={newButton} - /> - ) + setFacets({})} + Icon={header.Icon} + title={header.empty} + newButton={newButton} + /> ) : ( - - - {t("articles.columns.title")} - {catTax && {data.label(catTax, "singular")}} - {tagTax ? data.label(tagTax) : t("editor.tags")} - {t("articles.columns.author")} - {list.groupOf && {t("translationList.column")}} - {t("articles.columns.status")} - {t("articles.columns.updated")} - {viewsColumn && } - - {t("site.rowMenu")} - - - + 0 ? site.data : undefined} + collection={collection} + data={data} + translations={Boolean(list.groupOf)} + views={Boolean(viewsColumn)} + orderBy={listing.orderBy} + onSort={(o) => + setSearch({ + ...withoutSort(facets), + ...(o ? { sort: `${o.field}:${o.direction}` } : {}), + }) + } + /> {entries.isPending ? ( ) : ( @@ -258,6 +267,7 @@ export function ArticlesPage() { host={host} data={data} list={list} + fields={site.data && listFields > 0 ? site.data : undefined} views={viewsColumn} deleteDisabledReason={ entryAccess(e, { editAny, editOwnPublished }, self) === "edit" @@ -272,14 +282,7 @@ export function ArticlesPage() {
)} - {paging.total > paging.perPage && ( - - )} + !o && setToDelete(undefined)} @@ -295,6 +298,114 @@ export function ArticlesPage() { ); } +/** Nothing to list: no entries yet, or none matching the filters. */ +function ArticlesEmpty({ + filtered, + onClear, + Icon, + title, + newButton, +}: { + filtered: boolean; + onClear: () => void; + Icon: Icon; + title: string; + newButton: ReactNode; +}) { + const t = useT(); + return filtered ? ( + } + title={t("articles.facets.empty")} + description={t("articles.facets.emptyDescription")} + contents={ + + } + /> + ) : ( + } + title={title} + description={t("articles.emptyDescription")} + contents={newButton} + /> + ); +} + +/** The list's header row; `site` when the collection has listing fields (their columns, lazy). */ +function ArticlesHead({ + site, + collection, + data, + translations, + views, + orderBy, + onSort, +}: { + site: Site | undefined; + collection: string; + data: TaxonomyData; + translations: boolean; + views: boolean; + orderBy: FieldOrder | undefined; + onSort: (orderBy: FieldOrder | undefined) => void; +}) { + const t = useT(); + const { category: catTax, tag: tagTax } = data.defaults; + return ( + + + {t("articles.columns.title")} + {site && ( + + + + )} + {catTax && {data.label(catTax, "singular")}} + {tagTax ? data.label(tagTax) : t("editor.tags")} + {t("articles.columns.author")} + {translations && {t("translationList.column")}} + {t("articles.columns.status")} + {t("articles.columns.updated")} + {views && } + + {t("site.rowMenu")} + + + + ); +} + +/** Server pages (previous / next) while the list asks by field, else the client-side pages. */ +function ListPagination({ + server, + paging, +}: { + server: ServerPaging | undefined; + paging: { page: number; perPage: number; total: number; setPage: (page: number) => void }; +}) { + if (server) + return server.total > server.perPage ? ( + + ) : null; + return paging.total > paging.perPage ? ( + + ) : null; +} + /** One article; on a multilingual site's grouped list, with its group's Languages cell. */ function ArticleRow({ entry: e, @@ -302,6 +413,7 @@ function ArticleRow({ host, data, list, + fields, views, deleteDisabledReason, onDelete, @@ -311,6 +423,8 @@ function ArticleRow({ host: string | undefined; data: TaxonomyData; list: TranslationList; + /** The site, when the collection has listing fields to show (RFC-0042). */ + fields: Site | undefined; views: ListViewsColumn; deleteDisabledReason: string | undefined; onDelete: () => void; @@ -350,6 +464,11 @@ function ArticleRow({ + {fields && ( + + + + )} {catTax && ( {cats.names.length === 0 ? ( @@ -437,6 +556,12 @@ function ArticleRow({ type Facets = { category?: string | undefined; tag?: string | undefined }; +/** The URL search without the field sort. */ +function withoutSort(search: Record): Record { + const { sort: _s, ...rest } = search; + return rest; +} + /** The category and tag filters, which query the API's filter language (DESIGN.md §2p). */ function ArticleFacets({ data, diff --git a/apps/studio/src/routes/CollectionSettingsPage.tsx b/apps/studio/src/routes/CollectionSettingsPage.tsx index c84642fc..10d0f79b 100644 --- a/apps/studio/src/routes/CollectionSettingsPage.tsx +++ b/apps/studio/src/routes/CollectionSettingsPage.tsx @@ -21,6 +21,7 @@ import { import { SiteCrumbs } from "../components/Crumbs.tsx"; import { ErrorBanner } from "../components/ErrorBanner.tsx"; import { ReadOnly } from "../components/Gating.tsx"; +import { LISTING_SECTIONS } from "../components/listings/sections.ts"; import { SettingsLayout, SettingsSection } from "../components/Settings.tsx"; import { useTaxonomyData } from "../components/Taxonomy.tsx"; import { SwitchField } from "../components/TaxonomySettingsDialog.tsx"; @@ -36,6 +37,13 @@ const FieldsSection = lazy(() => import("../components/fields/FieldsSection.tsx").then((m) => ({ default: m.FieldsSection })), ); +// RFC-0042 §7: listing fields, expiry, structured data and "Create listing page", one lazy chunk. +const listingsModule = () => import("../components/listings/ListingSections.tsx"); +const ListingSections = lazy(() => listingsModule().then((m) => ({ default: m.ListingSections }))); +const CreateListingPage = lazy(() => + listingsModule().then((m) => ({ default: m.CreateListingPage })), +); + type Sort = NonNullable; type LabelForm = Record; @@ -92,7 +100,7 @@ export function withForm( * stays in Site settings › Collections. */ export function CollectionSettingsPage() { - const { t, locale } = useI18n("collections", "fields"); + const { t, locale } = useI18n("collections", "fields", "listings"); const { siteId, collection } = useParams({ from: "/sites/$siteId/collections/$collection/settings", }); @@ -115,6 +123,13 @@ export function CollectionSettingsPage() { } title={t("collections.settings.title", { name })} description={t("collections.settings.description")} + actions={ + site.data && ( + + + + ) + } > )} + {site.data && ( + }> + + + )} @@ -307,8 +335,14 @@ function SettingsForm({ /** The Fields section while its code loads. */ function FieldsSkeleton() { const { t } = useI18n("fields"); + return ; +} + +/** A settings section while its code loads. */ +function SectionSkeleton({ id, title }: { id: string; title?: string }) { + const { t } = useI18n("listings"); return ( - +
{t("common.loading")} diff --git a/apps/studio/test/listings-studio.test.tsx b/apps/studio/test/listings-studio.test.tsx new file mode 100644 index 00000000..b2e47696 --- /dev/null +++ b/apps/studio/test/listings-studio.test.tsx @@ -0,0 +1,737 @@ +/** + * RFC-0042 step 5 (collections stage 3 in Studio): the listing fields, expiry and structured-data + * cards (save, errors, compatible sources only, JSON-LD preview, read-only), the collection list's + * field columns, filters and sort (the query they build), "Create listing page", and the page + * editor's content list conditions. + */ +import type { + ContentEntry, + ContentListSection, + FieldDefinition, + Site, +} from "@cloudhal/core-contracts"; +import { PRESETS } from "@cloudhal/core-ts/structured-data"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ContentListQuery } from "../src/components/listings/ContentListQuery.tsx"; +import { CreateListingPage } from "../src/components/listings/CreateListingPage.tsx"; +import { ExpirySection } from "../src/components/listings/ExpirySection.tsx"; +import { ListFieldsSection } from "../src/components/listings/ListFieldsSection.tsx"; +import { StructuredDataSection } from "../src/components/listings/StructuredDataSection.tsx"; +import { sectionQueryIssues } from "../src/components/page-editor/sections.ts"; +import { en } from "../src/i18n/en.ts"; +import { isKnownTool } from "../src/lib/activity.ts"; +import { ApiRequestError } from "../src/lib/api/http.ts"; +import { listingQuery, withFilter } from "../src/lib/listing-query.ts"; +import { listingSearch } from "../src/lib/listing-search.ts"; +import { + listingSection, + presetOf, + sourceOptions, + suggestRequired, + withSetting, +} from "../src/lib/listings.ts"; +import { ArticlesPage } from "../src/routes/ArticlesPage.tsx"; +import { asRole, bodyOf, on, serve, wrap } from "./roles-fixtures.tsx"; + +const nav = vi.hoisted(() => ({ search: {} as Record, navigate: vi.fn() })); +vi.mock("@tanstack/react-router", async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ siteId: "site_1", collection: "events" }), + useNavigate: () => nav.navigate, + useSearch: () => nav.search, +})); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + nav.navigate.mockReset(); + nav.search = {}; +}); + +const L = (label: string) => ({ en: { label } }); +const DEFS: [FieldDefinition, ...FieldDefinition[]] = [ + { key: "startsAt", type: "datetime", labels: L("Starts"), localized: false }, + { key: "day", type: "date", labels: L("Day"), localized: false }, + { key: "venue", type: "text", labels: L("Venue"), localized: true }, + { key: "seats", type: "number", labels: L("Seats"), localized: false }, + { + key: "status", + type: "select", + labels: L("Status"), + localized: false, + options: [ + { value: "scheduled", labels: { en: "Scheduled" } }, + { value: "cancelled", labels: { en: "Cancelled" } }, + ], + }, +]; + +function siteWith(settings: Record = {}): Site { + return { + schemaVersion: 1, + id: "site_1", + projectId: "proj_1", + name: "Site", + slug: "site", + defaultLocale: "en", + locales: ["en"], + domains: [{ hostname: "example.com", primary: true }], + pageIds: [], + theme: { name: "blog" }, + timeZone: "Europe/Istanbul", + collections: { + events: { + labels: { en: { singular: "Event", plural: "Events" } }, + fields: DEFS, + ...settings, + }, + }, + rev: 7, + createdAt: "2026-10-18T10:00:00Z", + updatedAt: "2026-10-18T10:00:00Z", + } as unknown as Site; +} + +const entry = (id: string, fields: Record, extra = {}): ContentEntry => + ({ + schemaVersion: 1, + id, + siteId: "site_1", + collection: "events", + slug: id, + locale: "en", + status: "published", + title: id, + body: { format: "markdown", value: "" }, + fields, + tags: [], + author: { kind: "human", id: "u_owner" }, + rev: 1, + createdAt: "2026-10-18T10:00:00Z", + updatedAt: "2026-10-18T10:00:00Z", + ...extra, + }) as unknown as ContentEntry; + +const PUT = "/api/sites/site_1/collections/events"; +const saved = (init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { settings: unknown }; + return { + status: 200, + body: { site: { ...siteWith(), rev: 8, collections: { events: body.settings } } }, + }; +}; + +/** Opens a Kumo `Select` (by its accessible name) and picks an option. */ +async function choose(combobox: HTMLElement, option: string | RegExp) { + fireEvent.click(combobox); + const item = await screen.findByRole("option", { name: option }); + fireEvent.pointerDown(item, { pointerType: "mouse" }); + fireEvent.click(item); +} +const optionNames = async (combobox: HTMLElement) => { + fireEvent.click(combobox); + await screen.findAllByRole("option"); + const names = screen.getAllByRole("option").map((o) => o.textContent ?? ""); + fireEvent.keyDown(combobox, { key: "Escape" }); + return names; +}; + +describe("listings (lib)", () => { + it("turns URL filter tokens into conditions and keeps only well-formed params", () => { + expect( + listingSearch({ f_startsAt: "upcoming", f_Bad: "set", f_day: "nope", sort: "day:asc", x: 1 }), + ).toEqual({ + f_startsAt: "upcoming", + sort: "day:asc", + }); + const q = listingQuery({ + f_startsAt: "upcoming", + f_status: "is:cancelled", + f_seats: "unset", + sort: "seats:desc", + category: "news", + }); + expect(q.where).toEqual([ + { field: "startsAt", op: "gte", value: "now" }, + { field: "status", op: "eq", value: "cancelled" }, + { field: "seats", op: "unset" }, + ]); + expect(q.orderBy).toEqual({ field: "seats", direction: "desc" }); + expect(q.active).toBe(true); + expect(listingQuery({ category: "news" }).active).toBe(false); + expect(withFilter({ category: "news", f_day: "past" }, "day", undefined)).toEqual({ + category: "news", + }); + expect(withFilter({}, "day", "past")).toEqual({ f_day: "past" }); + }); + + it("offers only the sources that fit a structured-data path", () => { + const values = (path: string) => + sourceOptions(PRESETS.Event.paths[path] as never, DEFS).map((o) => o.value); + // A required date: date fields and the entry's dates, no text field, no fixed value. + expect(values("startDate")).toEqual([ + "none", + "field:startsAt", + "field:day", + "entry:publishedAt", + "entry:updatedAt", + ]); + // An optional enum: the select (single) and a fixed value. + expect(values("eventStatus")).toEqual(["none", "field:status", "constant"]); + // A text path with a default: the default first, text fields, entry and site text, a constant. + expect(values("organizer.name")).toEqual([ + "none", + "field:venue", + "entry:title", + "entry:summary", + "site:name", + "constant", + ]); + expect(values("name")[0]).toBe("default"); + expect(values("offers.price")).toEqual(["none", "field:seats"]); + }); + + it("builds presets, settings and listing sections", () => { + const preset = presetOf("Event", { + startDate: { choice: "field:startsAt", constant: "" }, + name: { choice: "default", constant: "" }, + eventStatus: { choice: "constant", constant: "scheduled" }, + "offers.priceCurrency": { choice: "constant", constant: " " }, + }); + expect(preset).toEqual({ + type: "Event", + properties: { + startDate: { kind: "field", field: "startsAt" }, + eventStatus: { kind: "constant", value: "scheduled" }, + }, + }); + expect(suggestRequired(preset, DEFS)).toEqual(["startsAt"]); + expect(withSetting({ icon: "x", listFields: ["day"] }, "listFields", [] as never)).toEqual({ + icon: "x", + }); + const settings = { + listFields: ["seats", "day"], + expiry: { field: "startsAt", action: "unlist" }, + } as never; + expect( + listingSection("sec_1", "events", { ...(settings as object), fields: DEFS } as never), + ).toEqual({ + id: "sec_1", + kind: "contentList", + collection: "events", + limit: 10, + sort: "newest", + where: [{ field: "day", op: "gte", value: "now" }], + orderBy: { field: "day", direction: "asc" }, + }); + expect(listingSection("sec_1", "events", { fields: DEFS })).not.toHaveProperty("where"); + }); + + it("words the new error codes, the expiry auto-check and the preview tool", () => { + for (const code of [ + "invalid_query", + "structured_data_invalid", + "invalid_expiry", + "list_field_in_use", + "gates_unsupported", + ] as const) + expect(en.errors.api[code]).toBeTruthy(); + expect(en.approvals.autoCheck.expires).toMatch(/expiry/); + expect(en.tools.preview_structured_data).toBe("Preview structured data"); + expect(isKnownTool("preview_structured_data")).toBe(true); + const error = new ApiRequestError(422, { + error: "invalid_query", + message: "x", + details: { + issues: [ + { path: "sections/1/where/0/value", code: "invalid_query", message: "not a number" }, + ], + }, + }); + expect(sectionQueryIssues(error, 1)).toEqual([ + { path: "where/0/value", message: "not a number" }, + ]); + expect(sectionQueryIssues(error, 0)).toEqual([]); + }); +}); + +describe("Collection settings › stage 3 cards", () => { + it("Listing fields: adds and orders fields, saves them alone", async () => { + const fetch = serve(on("PUT", PUT, saved)); + const site = siteWith({ listFields: ["seats"], icon: "calendar" }); + render( + wrap( + , + ), + ); + await choose(await screen.findByRole("combobox", { name: "Add a listing field" }), "Starts"); + fireEvent.click(screen.getByRole("button", { name: "Move Starts up" })); + expect(screen.getAllByText("Sortable")).toHaveLength(2); + fireEvent.click(screen.getByTestId("save-list-fields")); + await waitFor(() => + expect(bodyOf(fetch, "PUT", PUT)).toMatchObject({ + rev: 7, + settings: { icon: "calendar", listFields: ["startsAt", "seats"] }, + }), + ); + }); + + it("Listing fields: a field still used by sections names the pages", async () => { + serve( + on("PUT", PUT, { + status: 409, + body: { + error: "list_field_in_use", + message: "in use", + details: { pages: [{ id: "page_up", path: "/upcoming", locale: "en" }] }, + }, + }), + ); + const site = siteWith({ listFields: ["seats", "day"] }); + render( + wrap( + , + ), + ); + fireEvent.click(await screen.findByRole("button", { name: "Remove Day" })); + fireEvent.click(screen.getByTestId("save-list-fields")); + const box = await screen.findByTestId("list-fields-in-use"); + expect(within(box).getByRole("link", { name: "/upcoming" })).toHaveAttribute( + "href", + "/sites/site_1/pages/page_up", + ); + expect(screen.getByText(en.errors.api.list_field_in_use)).toBeInTheDocument(); + }); + + it("Expiry: date fields only, the action in words, grace, and how many are past it", async () => { + const fetch = serve( + on("PUT", PUT, saved), + on("GET", "/api/sites/site_1/entries", { + status: 200, + body: [ + entry("past", { startsAt: { at: "2020-01-01T10:00:00Z", timeZone: "UTC" } }), + entry("soon", { startsAt: { at: "2999-01-01T10:00:00Z", timeZone: "UTC" } }), + entry("none", {}), + ], + }), + ); + const site = siteWith(); + render( + wrap( + , + ), + ); + fireEvent.click(await screen.findByRole("switch", { name: "End entries on a date" })); + const field = await screen.findByRole("combobox", { name: /Date field/ }); + expect(await optionNames(field)).toEqual(["Starts", "Day"]); + expect(screen.getByText(/leaves lists, content list sections, feeds/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("radio", { name: /Hide the page/ })); + fireEvent.change(screen.getByTestId("expiry-grace"), { target: { value: "60" } }); + expect(await screen.findByText("1 published entry is already past it.")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("save-expiry")); + await waitFor(() => + expect(bodyOf(fetch, "PUT", PUT)?.settings).toMatchObject({ + expiry: { field: "startsAt", action: "hide", afterMinutes: 60 }, + }), + ); + }); + + it("Expiry: an older site Worker says to deploy first", async () => { + serve(on("PUT", PUT, { status: 409, body: { error: "gates_unsupported", message: "deploy" } })); + const site = siteWith({ expiry: { field: "day", action: "unlist" } }); + render( + wrap( + , + ), + ); + fireEvent.change(await screen.findByTestId("expiry-grace"), { target: { value: "30" } }); + fireEvent.click(screen.getByTestId("save-expiry")); + expect(await screen.findByTestId("expiry-gates-unsupported")).toHaveTextContent( + /Deploy the site first/, + ); + }); + + it("Structured data: maps paths with fitting sources only, saves, and shows refused issues", async () => { + const fetch = serve( + on("PUT", PUT, { + status: 422, + body: { + error: "structured_data_invalid", + message: "x", + details: { + issues: [ + { + path: "collections/events/structuredData/properties/location.name", + code: "required_missing", + message: "x", + }, + ], + }, + }, + }), + ); + const site = siteWith(); + render( + wrap( + , + ), + ); + await choose(await screen.findByRole("combobox", { name: "Type" }), "Event"); + await screen.findByTestId("preset-rows"); + const start = within( + document.querySelector('[data-path="startDate"]') as HTMLElement, + ).getByRole("combobox"); + const names = await optionNames(start); + expect(names).toEqual([ + "Not used", + "Field: Starts", + "Field: Day", + "Entry: Published date", + "Entry: Updated date", + ]); + expect(names).not.toContain("Field: Venue"); + // Live check, before saving: the required start date is not mapped yet. + const row = document.querySelector('[data-path="startDate"]') as HTMLElement; + expect(within(row).getByText(/Needed: choose where its value comes from/)).toBeInTheDocument(); + await choose(start, "Field: Starts"); + await waitFor(() => expect(within(row).queryByText(/Needed:/)).toBeNull()); + fireEvent.click(screen.getByTestId("save-structured-data")); + await waitFor(() => + expect(bodyOf(fetch, "PUT", PUT)?.settings.structuredData).toEqual({ + type: "Event", + properties: { startDate: { kind: "field", field: "startsAt" } }, + }), + ); + const location = document.querySelector('[data-path="location.name"]') as HTMLElement; + expect(await within(location).findAllByText(/Needed: choose/)).not.toHaveLength(0); + }); + + it("Structured data: previews a chosen entry's JSON-LD and why another emits none", async () => { + serve( + on("GET", "/api/sites/site_1/entries", { + status: 200, + body: [entry("launch", {}), entry("draft", {})], + }), + on("GET", "/api/sites/site_1/entries/launch/structured-data", { + status: 200, + body: { + preset: "Event", + origin: "https://example.com", + document: { "@type": "Event", name: "launch" }, + issues: [], + }, + }), + on("GET", "/api/sites/site_1/entries/draft/structured-data", { + status: 200, + body: { + preset: "Event", + origin: "https://example.com", + document: null, + issues: [ + { + path: "collections/events/structuredData/properties/startDate", + code: "required_value_missing", + message: "x", + }, + ], + }, + }), + ); + const site = siteWith({ + structuredData: { + type: "Event", + properties: { startDate: { kind: "field", field: "startsAt" } }, + }, + }); + render( + wrap( + , + ), + ); + const preview = await screen.findByTestId("structured-data-preview"); + await waitFor(() => + expect(within(preview).getByRole("combobox", { name: "Entry" })).not.toHaveAttribute( + "data-disabled", + ), + ); + await new Promise((r) => setTimeout(r, 50)); + await choose(within(preview).getByRole("combobox", { name: "Entry" }), /^launch/); + await waitFor(() => expect(preview.textContent).toContain("@type")); + expect(within(preview).getByRole("link", { name: /Rich Results Test/ })).toHaveAttribute( + "href", + `https://search.google.com/test/rich-results?url=${encodeURIComponent("https://example.com/events/launch")}`, + ); + await choose(within(preview).getByRole("combobox", { name: "Entry" }), /^draft/); + expect( + await within(preview).findByText(/no value for a required property \(startDate\)/), + ).toBeInTheDocument(); + expect(within(preview).getByText(/emits no structured data/)).toBeInTheDocument(); + }); + + it("is read-only without site:settings", async () => { + serve(); + const site = siteWith({ listFields: ["seats"], expiry: { field: "day", action: "unlist" } }); + const props = { + site, + collection: "events", + settings: site.collections?.events ?? {}, + readOnlyReason: "Only Administrators change site settings.", + }; + render( + wrap( + <> + + + + , + ), + ); + expect(await screen.findByTestId("save-list-fields")).toBeDisabled(); + expect(screen.getByRole("button", { name: "Remove Seats" })).toBeDisabled(); + expect(screen.getByRole("combobox", { name: "Add a listing field" })).toHaveAttribute( + "data-disabled", + ); + expect(screen.getByTestId("save-expiry")).toBeDisabled(); + expect(screen.getByRole("switch", { name: "End entries on a date" })).toBeDisabled(); + expect(screen.getByTestId("save-structured-data")).toBeDisabled(); + }); +}); + +describe("Collection list › listing fields", () => { + const routes = (site: Site) => [ + ...asRole("owner"), + on("GET", "/api/sites/site_1", { status: 200, body: site }), + ]; + + it("shows formatted columns and filters, and a filter goes to the URL", async () => { + const site = siteWith({ listFields: ["startsAt", "seats", "status"] }); + serve( + ...routes(site), + on("GET", "/api/sites/site_1/entries", { + status: 200, + body: [ + entry("launch", { + startsAt: { at: "2026-11-05T16:30:00Z", timeZone: "Europe/Istanbul" }, + seats: 1200, + status: "cancelled", + }), + ], + }), + ); + render(wrap()); + const row = await waitFor(() => { + const r = document.querySelector('tr[data-entry-id="launch"]'); + expect(r).not.toBeNull(); + return r as HTMLElement; + }); + await waitFor(() => + expect(row.querySelector('[data-list-cell="seats"]')).toHaveTextContent("1,200"), + ); + expect(row.querySelector('[data-list-cell="status"]')).toHaveTextContent("Cancelled"); + expect(row.querySelector('[data-list-cell="startsAt"]')?.textContent).toMatch( + /November 5, 2026/, + ); + await choose(screen.getByRole("combobox", { name: "Starts" }), "Starts: upcoming"); + expect(nav.navigate).toHaveBeenLastCalledWith( + expect.objectContaining({ search: { f_startsAt: "upcoming" } }), + ); + fireEvent.click(screen.getByTestId("list-sort-seats")); + expect(nav.navigate).toHaveBeenLastCalledWith( + expect.objectContaining({ search: { sort: "seats:asc" } }), + ); + }); + + it("asks the API by field while the URL filters or sorts, one page at a time", async () => { + nav.search = { f_startsAt: "upcoming", f_status: "is:scheduled", sort: "seats:desc" }; + const site = siteWith({ listFields: ["startsAt", "seats", "status"] }); + const fetch = serve( + ...routes(site), + on("GET", "/api/sites/site_1/entries", (init) => ({ + status: 200, + body: { entries: [entry("a", {}), entry("b", {})], total: 30, nextCursor: "next1", init }, + })), + ); + render(wrap()); + await waitFor(() => expect(document.querySelectorAll("tr[data-entry-id]")).toHaveLength(2)); + const urls = fetch.mock.calls.map(([u]) => decodeURIComponent(String(u))); + const asked = urls.find((u) => u.includes("where=")); + expect(asked).toContain( + 'where=[{"field":"startsAt","op":"gte","value":"now"},{"field":"status","op":"eq","value":"scheduled"}]', + ); + expect(asked).toContain('orderBy={"field":"seats","direction":"desc"}'); + expect(asked).toContain("limit=25"); + fireEvent.click(await screen.findByRole("button", { name: /next/i })); + await waitFor(() => + expect(fetch.mock.calls.some(([u]) => String(u).includes("cursor=next1"))).toBe(true), + ); + }); +}); + +describe("Create listing page", () => { + it("creates a draft page at the collection's address with an upcoming content list", async () => { + const site = siteWith({ + listFields: ["startsAt"], + expiry: { field: "startsAt", action: "unlist" }, + }); + const fetch = serve( + ...asRole("owner"), + on("GET", "/api/sites/site_1/pages", { status: 200, body: [] }), + on("POST", "/api/sites/site_1/pages", { + status: 201, + body: { page: { id: "page_events", path: "/events" }, site }, + }), + ); + render(wrap()); + await waitFor(() => expect(screen.getByTestId("create-listing-page")).toBeEnabled()); + fireEvent.click(screen.getByTestId("create-listing-page")); + await waitFor(() => + expect(nav.navigate).toHaveBeenCalledWith( + expect.objectContaining({ params: { siteId: "site_1", pageId: "page_events" } }), + ), + ); + expect(bodyOf(fetch, "POST", "/api/sites/site_1/pages")).toMatchObject({ + path: "/events", + title: "Events", + locale: "en", + status: "draft", + sections: [ + { + kind: "contentList", + collection: "events", + where: [{ field: "startsAt", op: "gte", value: "now" }], + orderBy: { field: "startsAt", direction: "asc" }, + }, + ], + }); + }); + + it("opens the page that already lives at that address", async () => { + serve( + ...asRole("owner"), + on("GET", "/api/sites/site_1/pages", { + status: 200, + body: [{ id: "page_old", path: "/events", locale: "en" }], + }), + ); + render(wrap()); + expect(await screen.findByTestId("open-listing-page")).toHaveAttribute( + "href", + "/sites/site_1/pages/page_old", + ); + }); +}); + +describe("Page editor › content list conditions", () => { + function Harness({ + initial, + issues = [], + }: { + initial: ContentListSection; + issues?: { path: string; message: string }[]; + }) { + const [section, setSection] = useState(initial); + return ( + <> + + {JSON.stringify(section)} + + ); + } + const base: ContentListSection = { + id: "sec_1", + kind: "contentList", + collection: "events", + limit: 6, + sort: "newest", + }; + const current = () => + JSON.parse(screen.getByTestId("section").textContent ?? "{}") as ContentListSection; + + it("adds conditions with the field type's operators and orders by a date", async () => { + serve( + on("GET", "/api/sites/site_1", { + status: 200, + body: siteWith({ listFields: ["startsAt", "venue"] }), + }), + ); + render(wrap()); + fireEvent.click(await screen.findByTestId("add-condition")); + expect(current().where).toEqual([{ field: "startsAt", op: "eq", value: "now" }]); + const op = within( + document.querySelector('[data-condition="startsAt"]') as HTMLElement, + ).getByRole("combobox", { name: "Condition" }); + expect(await optionNames(op)).toEqual([ + "is", + "is not", + "is before", + "is on or before", + "is after", + "is on or after", + "has a value", + "is empty", + ]); + await choose(op, "is on or after"); + expect(current().where).toEqual([{ field: "startsAt", op: "gte", value: "now" }]); + await choose(screen.getByRole("combobox", { name: "Order by field" }), "Starts"); + expect(current().orderBy).toEqual({ field: "startsAt", direction: "asc" }); + await choose(screen.getByRole("combobox", { name: "Direction" }), "Latest first"); + expect(current().orderBy).toEqual({ field: "startsAt", direction: "desc" }); + fireEvent.click(screen.getByRole("button", { name: "Remove condition" })); + expect(current()).not.toHaveProperty("where"); + }); + + it("shows the save's invalid_query issues on their row, and a hint without listing fields", async () => { + serve( + on("GET", "/api/sites/site_1", { status: 200, body: siteWith({ listFields: ["seats"] }) }), + ); + const section = { + ...base, + where: [{ field: "seats", op: "gt", value: "ten" }], + } as ContentListSection; + render( + wrap( + , + ), + ); + expect(await screen.findByText("not a number")).toBeInTheDocument(); + cleanup(); + serve(on("GET", "/api/sites/site_1", { status: 200, body: siteWith() })); + render(wrap()); + expect(await screen.findByRole("link", { name: "Choose listing fields" })).toHaveAttribute( + "href", + "/sites/site_1/collections/events/settings#listing-fields", + ); + }); +}); diff --git a/docs/design/DESIGN.md b/docs/design/DESIGN.md index 74a6f8e2..72e120ba 100644 --- a/docs/design/DESIGN.md +++ b/docs/design/DESIGN.md @@ -482,7 +482,11 @@ Words in the lazy `fields` namespace (loaded with the entry editor and collectio - **Collection settings › Fields** (`FieldsSection`, `SettingsSection #fields` after "Listing", in the table of contents; a skeleton section while its chunk loads): rows separated by hairlines — the label (bold), the key in mono, a secondary type `Badge`, info "Per language", outline "Needed to publish", beta "Not saved" for fields added in the draft, warning "Being changed" while an operation's marker is on it; ghost square up/down buttons and a ⋯ `DropdownMenu` (Edit…; for saved fields Rename…, Change type… (only types the conversion table allows), Languages…, Remove… (danger); for unsaved ones Remove). Header action: secondary `Plus` "Add field" (disabled at 30). Footer: ghost "Discard" while dirty, primary "Save fields" — one `PUT /collections/:key` with the whole list (free edits only). Operations wait until the draft is saved or discarded (a muted line says so). Empty: `SectionEmpty` (`Textbox`). Without `site:settings`, the page's `ReadOnly` fieldset and `readOnlyReason` disable every control. - **Add / edit field** (`FieldDialog`, `LayerDialog lg`): type `Select` (new fields only), label and help per site language (`Input` pairs, two columns from `sm`; the default language's label is required), the key (mono, derived from the default label until edited — `fieldKeyFrom` — and checked against the contract's pattern, the reserved keys and the collection's other keys; disabled once saved), `SwitchField`s "A value per language" (pre-set on for text, rich text and links) and "Needed to publish" (not for yes/no), then the type's own: maximum length, "Several lines" and "Include in site search" (text), search (rich text), minimum / maximum and "Whole numbers only" (number), options as `InsetPanel`s with a label per language and a mono value (derived from the default label until edited), "Add option" and "Allow several" (select). Errors show after the first Apply. A saved field keeps key, type, localization and `multiple` disabled with the reason: those change through operations. - **Operation dialogs** (`FieldOperationDialog`, `LayerDialog lg`, one for rename, change type, languages and remove): the inputs (new key; new type and, for a select, its options), then primary "Check what changes" runs the dry run (languages and removal run it on open). The report (`FieldOperationReport`): an outline `InsetPanel` `dl` of entries checked and changed and the non-zero counts (moved, converted, cleared, removed, can't be converted in danger text), per language on multilingual sites, and up to five recessed sample panels (kind `Badge`, entry id · locale in mono, before → after with a `DirectionalIcon` arrow). Then an alert `Banner` about themes reading `entry.fields.` (rename, type), the `Checkbox` "Clear n values that can't be converted" that must be ticked when the dry run found any, and the `Camera` snapshot line ("…the live site changes only when you publish"). The primary becomes the operation's verb ("Rename field", "Remove field"); running shows a `Loader` with the entries updated so far while Studio continues every `done: false`; finished, a `Banner` "Done · n entries changed." with `Banner.Action` "Revert", then "Reverted". The dialog can't be dismissed while it runs. A field carrying a running operation's marker puts an alert `Banner` in the section ("A field change is still running · n entries left", polled) with "Continue", which reopens the dialog on that run (or its revert). -- **Approvals and Activity**: `rename_field`, `change_field_type`, `set_field_localized`, `remove_field`, `list_collections`, `create_collection` and `update_collection` have labels in `tools.*`; `change_field_type` and `remove_field` are `CONFIRM_TOOLS` (the server's dry-run prompt shows, and approving asks first). Field error codes read through `apiErrorText` (`errors.api.*`). +- **Collection settings › Listing fields, Expiry, Structured data** (RFC-0042 §7, ADR-0044; `components/listings/`, words in the lazy `listings` namespace, `LAZY_ONLY` with `lib/listings.ts`, `lib/listing-query.ts`, `lib/api/listings.ts` and `@cloudhal/core-ts/structured-data`): three `SettingsSection`s after Fields, in the table of contents (`#listing-fields`, `#expiry`, `#structured-data`), one lazy chunk with a skeleton section while it loads. Each saves only its own setting (`PUT /collections/:key` with the current settings and that key replaced): footer ghost "Discard" while dirty, primary "Save …" (gated, `title` = reason); without `site:settings` the page's `ReadOnly` fieldset and `readOnlyReason` disable every control. **Listing fields**: up to four chosen fields as hairline rows (label bold, key mono, secondary type `Badge`, outline "Sortable" for number/date/datetime; ghost square up / down / remove), then a `Select` "Add a listing field" (disabled at four, with the reason as its description); empty → `SectionEmpty` (`Columns`); a 409 `list_field_in_use` shows the error `Banner` and links to the pages whose content lists still use the field. **Expiry**: `SwitchField` "End entries on a date", the date field `Select` (date and date-and-time fields only; none → a muted line), the action as `Radio.Group appearance="card"` ("Leave lists" / "Hide the page", each with RFC-0042 §3.3 in words), the grace in minutes (`Input type=number`, 0–10,080), and a live secondary line counting published entries already past it (counted in Studio with `fieldGateInstant`). A 409 `gates_unsupported` becomes an alert `Banner` (`CloudArrowUp`) that says to deploy the site first. **Structured data**: a type `Select` (None = blog post, Event, Job posting, Product, Recipe), then hairline rows of the type's properties — required first (warning `Badge` "Required", outline "One of these" for address groups) — each path in mono with a `Select` of only the sources that fit (its default, fields of a fitting type, entry and site properties, "A fixed value" where allowed; a fixed value on an enum path is a vocabulary `Select`, a currency gets the ISO hint). `checkPreset` runs while editing and the refused save's `details.issues` join it, worded per code under their row (`Text variant="error"`). Muted lines suggest "Needed to publish" for fields behind required properties and remind that the theme should show mapped fields. With a saved preset, an `InsetPanel` previews a chosen entry (`GET …/entries/:id/structured-data`): issues, the JSON-LD in `CodeHighlighted`, and for a published entry on a domain a secondary `LinkButton` to Google's Rich Results Test. +- **Create listing page** (RFC-0042 §5): a secondary header action on collection settings (`pages.write` only) that creates a draft page at the collection's prefix titled with its plural, holding one `contentList` (upcoming first, soonest first, when the collection has a date listing field and an expiry), then opens it in the page editor. When a page already lives there it becomes "Open listing page" (`LinkButton`, the address in its `title`); a refused write is an error toast. +- **Collection list › listing fields** (RFC-0042 §4): a collection with `listFields` shows them as columns after the title (values through `formatFieldValue` in the Studio language, "—" when empty), a `Toolbar` of `Select`s after the facets — one per field with type-fitting choices (dates: upcoming / past / has a value / empty; yes / no; each option of a choice; has a value / empty) — and ghost sort buttons in number and date headers (ascending → descending → off, `aria-sort`). Filters and sort live in the URL (`f_=`, `sort=:asc|desc`, `lib/listing-search.ts`); while any is set, the list asks `GET …/entries` with `where`/`orderBy`/`limit`/`cursor` one page at a time (`Pagination controls="simple"`), "Clear filters" clears facets and field filters together. The columns, filters and sort are a lazy chunk. +- **Page editor › Content list** (RFC-0042 §4): under collection / limit / sort, a lazy "Only entries where" block over the listed collection's listing fields — rows of field, condition (only that type's operators, worded "is before / after" for dates), value (now or a date for dates, the options for a choice, a `Checkbox.Group` for "is one of", yes / no) and a ghost square remove; secondary "Add condition" (up to four); "Order by field" (number and date fields) with its direction. A save refused with `invalid_query` shows each issue under its row. A collection without listing fields shows a muted line with a link to its settings. +- **Approvals and Activity**: `rename_field`, `change_field_type`, `set_field_localized`, `remove_field`, `list_collections`, `create_collection`, `update_collection` and `preview_structured_data` have labels in `tools.*`; `change_field_type` and `remove_field` are `CONFIRM_TOOLS` (the server's dry-run prompt shows, and approving asks first). Field and RFC-0042 error codes (`invalid_query`, `structured_data_invalid`, `invalid_expiry`, `list_field_in_use`, `gates_unsupported`) read through `apiErrorText` (`errors.api.*`); a 409 whose code has its own words shows them, not "someone else changed this". An `auto`-mode publish held back by a new or earlier collection expiry gives the reason `approvals.autoCheck.expires`. ## 3. Sidebar specification ```tsx diff --git a/docs/engineering/code-structure.md b/docs/engineering/code-structure.md index 1f52fb65..c2dfbc9f 100644 --- a/docs/engineering/code-structure.md +++ b/docs/engineering/code-structure.md @@ -70,7 +70,7 @@ of cases. Test files still have a file cap; split suites by behaviour (`store.pu Generated and vendored code is never measured: `packages/core-contracts/src/generated`, `crates/cloudhal-schema/src/generated`, `packages/core-ts/src/generated`, -`packages/ui/src/blocks/kumo`, `dist`, `node_modules`, `*.d.ts`, tool configs. +`apps/studio/src/i18n/generated` (message parameter types), `packages/ui/src/blocks/kumo`, `dist`, `node_modules`, `*.d.ts`, tool configs. ## 3. The ratchet diff --git a/packages/api/src/playground.ts b/packages/api/src/playground.ts index f81a8ef7..948f4488 100644 --- a/packages/api/src/playground.ts +++ b/packages/api/src/playground.ts @@ -130,6 +130,7 @@ export const PLAYGROUND_ALLOW: ReadonlySet = new Set([ `POST ${S}/entries`, `GET ${S}/entries/:entryId/preview`, `POST ${S}/entries/:entryId/preview`, + `GET ${S}/entries/:entryId/structured-data`, `POST ${S}/entries/preview-fragment`, `GET ${S}/entries/:entryId`, `PUT ${S}/entries/:entryId`, diff --git a/packages/api/src/routes/entries.ts b/packages/api/src/routes/entries.ts index 533a3c73..b3a0ad86 100644 --- a/packages/api/src/routes/entries.ts +++ b/packages/api/src/routes/entries.ts @@ -14,6 +14,7 @@ import { API_ACTOR, writerOf } from "../governance.ts"; import { editsLiveCorrections, entryLimit, personOf } from "../own.ts"; import { param } from "../params.ts"; import { type DraftPreview, previewResponse } from "../preview.ts"; +import { previewStructuredData } from "../structured-data.ts"; const STATUSES = new Set(["draft", "review", "published", "archived"]); @@ -120,6 +121,15 @@ export const entries: Hono = new Hono() html: renderEntryPreview(site, draft, { blocks: store.blocks }).html, } satisfies DraftPreview); }) + // RFC-0042 §7: the JSON-LD the entry's page emits under its collection's preset (read-only). + .get("/:entryId/structured-data", async (c) => { + const store = c.get("store"); + const site = await store.getSite(param(c, "siteId")); + if (!site) throw notFound("site not found"); + const entry = await store.getContentEntry(param(c, "entryId")); + if (!entry || entry.siteId !== site.id) throw notFound("entry not found"); + return c.json(await previewStructuredData(store, site, entry)); + }) .get("/:entryId", async (c) => { const entry = await c.get("store").getContentEntry(param(c, "entryId")); if (!entry || entry.siteId !== param(c, "siteId")) throw notFound("entry not found"); diff --git a/packages/api/src/scopes.ts b/packages/api/src/scopes.ts index 955179a3..2c8645e1 100644 --- a/packages/api/src/scopes.ts +++ b/packages/api/src/scopes.ts @@ -155,6 +155,7 @@ export const ROUTE_SCOPES: readonly (readonly [Method, string, RouteRequirement] ["GET", `${S}/entries`, "content:read"], ["POST", `${S}/entries`, "content:draft:own"], ["GET", `${S}/entries/:entryId/preview`, "content:read"], + ["GET", `${S}/entries/:entryId/structured-data`, "content:read"], ["POST", `${S}/entries/:entryId/preview`, "content:read"], // the body editor's inline preview of one ```surface (writes nothing) ["POST", `${S}/entries/preview-fragment`, "content:read"], diff --git a/packages/api/src/structured-data.ts b/packages/api/src/structured-data.ts new file mode 100644 index 00000000..6e756f5e --- /dev/null +++ b/packages/api/src/structured-data.ts @@ -0,0 +1,91 @@ +/** + * RFC-0042 §7: `GET /api/sites/:siteId/entries/:entryId/structured-data`, Studio's read-only twin of + * MCP's `preview_structured_data`. It answers the JSON-LD document an entry's page emits under its + * collection's preset (the renderer's own `entryStructuredData`, so exactly what the site emits), + * why it emits none (no preset, a required value missing, over the cap) and the preset's warnings. + * Drafts work too; nothing is written. + */ +import { entryStructuredData } from "@cloudhal/astro-renderer/structured-data"; +import type { Asset, ContentEntry, Site } from "@cloudhal/core-contracts"; +import { primaryOrigin, type SiteGraphStore } from "@cloudhal/core-ts"; +import { checkStructuredData, type StructuredDataIssue } from "@cloudhal/core-ts/structured-data"; + +export interface StructuredDataPreview { + /** The collection's preset type; null without one. */ + preset: string | null; + /** The origin every URL in the document uses (the primary domain, else a local placeholder). */ + origin: string; + document: object | null; + issues: StructuredDataIssue[]; +} + +/** Without a domain, URLs are shown on the local preview server's origin. */ +const LOCAL_ORIGIN = "http://localhost:4321"; + +/** Every `assetId` a JSON value names (image fields, cover images, the site's logos). */ +function assetIds(value: unknown, out = new Set()): Set { + if (Array.isArray(value)) for (const v of value) assetIds(v, out); + else if (value && typeof value === "object") + for (const [k, v] of Object.entries(value)) { + if (k === "assetId" && typeof v === "string") out.add(v); + else assetIds(v, out); + } + return out; +} + +async function assetsFor(store: SiteGraphStore, site: Site, nodes: readonly unknown[]) { + const ids = new Set(); + for (const n of nodes) assetIds(n, ids); + const found = await Promise.all([...ids].map((i) => store.getAsset(i))); + return found.filter((a): a is Asset => a !== undefined && a.siteId === site.id); +} + +/** The preset's save-time warnings for one collection (select options outside a vocabulary). */ +function presetWarnings(site: Site, key: string): StructuredDataIssue[] { + const only = site.collections?.[key]; + if (!only?.structuredData) return []; + return checkStructuredData({ ...site, collections: { [key]: only } }).filter((i) => i.warning); +} + +/** The preview of `entry` (already checked to belong to `site`). */ +export async function previewStructuredData( + store: SiteGraphStore, + site: Site, + entry: ContentEntry, +): Promise { + const origin = primaryOrigin(site) ?? LOCAL_ORIGIN; + const preset = site.collections?.[entry.collection]?.structuredData; + if (!preset) + return { + preset: null, + origin, + document: null, + issues: [ + { + path: `collections/${entry.collection}/structuredData`, + // The MCP tool's code; not a save-time issue code. + code: "no_preset" as StructuredDataIssue["code"], + message: `the collection ${entry.collection} has no structured-data preset, so its pages emit BlogPosting`, + }, + ], + }; + const group = entry.translation?.group; + const original = group && group !== entry.id ? await store.getContentEntry(group) : undefined; + const sameSite = original?.siteId === site.id ? original : undefined; + const assets = await assetsFor(store, site, [entry, sameSite, site.brand]); + const result = await entryStructuredData( + { + site: async () => site, + entryById: async (key: string) => (key === sameSite?.id ? sameSite : undefined), + assets: async () => assets, + }, + entry, + { origin }, + ); + return { + preset: preset.type, + origin, + document: result.doc ?? null, + issues: [...result.issues, ...presetWarnings(site, entry.collection)], + }; +} diff --git a/packages/api/src/testing-listings.ts b/packages/api/src/testing-listings.ts index 3819b737..3674b327 100644 --- a/packages/api/src/testing-listings.ts +++ b/packages/api/src/testing-listings.ts @@ -240,5 +240,32 @@ export function describeListingsApi(label: string, factory: ApiStoreFactory): vo ["collection invalid_query"], ]); }); + + it("GET /entries/:id/structured-data previews the preset document and its issues", async () => { + await seeded(); + const city = { startsAt: at("2026-11-05T16:30:00Z"), city: "Istanbul" }; + expect((await entry("d", city)).status).toBe(201); + const all = (await list({})).body as unknown as Json[]; + const id = (slug: string) => all.find((e) => e.slug === slug)?.id as string; + const preview = (entryId: string) => + call(`/api/sites/${siteId}/entries/${entryId}/structured-data`); + const d = await preview(id("d")); + expect(d.status, JSON.stringify(d.body)).toBe(200); + expect(d.body).toMatchObject({ + preset: "Event", + origin: expect.stringMatching(/^https?:\/\//), + document: { "@type": "Event", name: "d", startDate: "2026-11-05T19:30:00+03:00" }, + }); + // No start date: no document, and the reason. + const c = await preview(id("c")); + expect(c.body.document).toBeNull(); + expect((c.body.issues as { code: string }[]).map((i) => i.code)).toContain( + "required_value_missing", + ); + expect((await preview("entry_missing")).status).toBe(404); + expect((await put({ ...EVENTS, structuredData: undefined })).status).toBe(200); + const none = await preview(id("d")); + expect([none.body.preset, none.body.document]).toEqual([null, null]); + }); }); } diff --git a/scripts/code-size/measure.ts b/scripts/code-size/measure.ts index d8b0672d..bb652b36 100644 --- a/scripts/code-size/measure.ts +++ b/scripts/code-size/measure.ts @@ -25,6 +25,8 @@ const IGNORED = [ /^packages\/core-contracts\/src\/generated\//, /^crates\/cloudhal-schema\/src\/generated\//, /^packages\/core-ts\/src\/generated\//, + // Studio's message parameter types, written by `pnpm gen` from the catalogs (RFC-0032 §3). + /^apps\/studio\/src\/i18n\/generated\//, /^packages\/ui\/src\/blocks\/kumo\//, /\.d\.ts$/, /(^|\/)(vite|vitest|astro)\.config\.[cm]?[jt]s$/, diff --git a/scripts/size-baseline.json b/scripts/size-baseline.json index 6323f6b8..b93bef79 100644 --- a/scripts/size-baseline.json +++ b/scripts/size-baseline.json @@ -184,7 +184,7 @@ "lines": 446 }, "apps/studio/src/routes/ArticlesPage.tsx::ArticlesPage": { - "lines": 197 + "lines": 192 }, "apps/studio/src/routes/AssetPage.tsx::AssetPage": { "lines": 739