From 36788551184d0ce1bf0bc59c96b8fb7ddc05ceea Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:37:07 +0300 Subject: [PATCH 1/4] feat(studio,ui,api): RFC-0045 step 6a, reference and repeater fields in the entry editor New @cloudhal/ui blocks, built from Kumo: FieldControl (one typed control, extracted from EntryFieldsForm), RepeaterField (items as fieldsets with ICU legends, add/move/duplicate/remove through buttons, focus after add and remove, undo, a polite live region, errors opening their items), ReferenceList, EntryPicker (labelled search, listbox with arrow keys and Enter, polite result count, cursor and truncated reads, focus back to the trigger; no API calls of its own) and UsedByList. Studio's entry editor renders references through FieldReference (around GET .../entries/pick and the new GET .../entries/groups), repeaters with FieldImage and FieldReference sub-fields, item errors by path, translation fallbacks, an undo toast, and a "Used by" card. Everything loads with the lazy fields chunks; the initial bundle is unchanged. Words live in the lazy fields namespace; every other language is an AI draft through i18n:import. Both types stay refused on every production write path until step 7. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/rfc45-step6a-studio-editor.md | 10 + apps/studio/bundle-budget.ts | 6 +- .../studio/src/components/EntryFieldCards.tsx | 67 +++ .../src/components/fields/EntryFieldsCard.tsx | 76 ++- .../src/components/fields/FieldReference.tsx | 207 +++++++ .../src/components/fields/UsedByCard.tsx | 121 +++++ .../src/components/fields/useGroupCards.ts | 45 ++ apps/studio/src/i18n/generated/params.ts | 23 + apps/studio/src/i18n/lazy/fields.ar.ts | 76 ++- apps/studio/src/i18n/lazy/fields.de.ts | 77 ++- apps/studio/src/i18n/lazy/fields.en.ts | 74 +++ apps/studio/src/i18n/lazy/fields.es.ts | 77 ++- apps/studio/src/i18n/lazy/fields.fa.ts | 76 ++- apps/studio/src/i18n/lazy/fields.fr.ts | 78 ++- apps/studio/src/i18n/lazy/fields.he.ts | 75 ++- apps/studio/src/i18n/lazy/fields.hi.ts | 74 ++- apps/studio/src/i18n/lazy/fields.id.ts | 74 ++- apps/studio/src/i18n/lazy/fields.it.ts | 76 ++- apps/studio/src/i18n/lazy/fields.ja.ts | 77 ++- apps/studio/src/i18n/lazy/fields.ko.ts | 72 ++- apps/studio/src/i18n/lazy/fields.nl.ts | 77 ++- apps/studio/src/i18n/lazy/fields.pl.ts | 76 ++- apps/studio/src/i18n/lazy/fields.pt-BR.ts | 77 ++- apps/studio/src/i18n/lazy/fields.ru.ts | 77 ++- apps/studio/src/i18n/lazy/fields.tr.ts | 75 ++- apps/studio/src/i18n/lazy/fields.uk.ts | 77 ++- apps/studio/src/i18n/lazy/fields.zh-Hans.ts | 71 ++- apps/studio/src/lib/api/references.ts | 70 +++ apps/studio/src/lib/field-references.ts | 84 +++ apps/studio/src/routes/ArticleEditorPage.tsx | 28 +- apps/studio/test/fields-references.test.tsx | 259 +++++++++ apps/studio/test/i18n-messages.test.ts | 4 +- docs/design/DESIGN.md | 4 +- ...ctions-stage-4-references-and-repeaters.md | 10 + packages/api/src/playground.ts | 3 +- packages/api/src/routes/entry-references.ts | 28 +- packages/api/src/scopes.ts | 1 + packages/api/src/testing-references.ts | 32 ++ packages/core-ts/src/fields/types.ts | 10 +- packages/ui/src/blocks/EntryFieldsForm.tsx | 514 +++++------------- packages/ui/src/blocks/EntryPicker.tsx | 364 +++++++++++++ packages/ui/src/blocks/EntryRepeater.tsx | 71 +++ packages/ui/src/blocks/FieldControl.tsx | 498 +++++++++++++++++ packages/ui/src/blocks/ReferenceList.tsx | 210 +++++++ packages/ui/src/blocks/RepeaterField.tsx | 317 +++++++++++ packages/ui/src/blocks/UsedByList.tsx | 104 ++++ packages/ui/src/blocks/entry-fields-model.ts | 108 +++- packages/ui/src/blocks/repeater-state.ts | 158 ++++++ packages/ui/src/index.ts | 35 ++ packages/ui/test/entry-fields-form.test.tsx | 194 ++++++- packages/ui/test/entry-picker.test.tsx | 193 +++++++ packages/ui/test/field-labels.ts | 84 +++ packages/ui/test/repeater-field.test.tsx | 163 ++++++ scripts/size-baseline.json | 8 +- 54 files changed, 5088 insertions(+), 457 deletions(-) create mode 100644 .changeset/rfc45-step6a-studio-editor.md create mode 100644 apps/studio/src/components/EntryFieldCards.tsx create mode 100644 apps/studio/src/components/fields/FieldReference.tsx create mode 100644 apps/studio/src/components/fields/UsedByCard.tsx create mode 100644 apps/studio/src/components/fields/useGroupCards.ts create mode 100644 apps/studio/src/lib/api/references.ts create mode 100644 apps/studio/src/lib/field-references.ts create mode 100644 apps/studio/test/fields-references.test.tsx create mode 100644 packages/ui/src/blocks/EntryPicker.tsx create mode 100644 packages/ui/src/blocks/EntryRepeater.tsx create mode 100644 packages/ui/src/blocks/FieldControl.tsx create mode 100644 packages/ui/src/blocks/ReferenceList.tsx create mode 100644 packages/ui/src/blocks/RepeaterField.tsx create mode 100644 packages/ui/src/blocks/UsedByList.tsx create mode 100644 packages/ui/src/blocks/repeater-state.ts create mode 100644 packages/ui/test/entry-picker.test.tsx create mode 100644 packages/ui/test/field-labels.ts create mode 100644 packages/ui/test/repeater-field.test.tsx diff --git a/.changeset/rfc45-step6a-studio-editor.md b/.changeset/rfc45-step6a-studio-editor.md new file mode 100644 index 00000000..81fee596 --- /dev/null +++ b/.changeset/rfc45-step6a-studio-editor.md @@ -0,0 +1,10 @@ +--- +"@cloudhal/api": patch +"@cloudhal/core-ts": patch +--- + +RFC-0045 rollout step 6a: reference and repeater fields in Studio's entry editor. The two types stay refused on every production write path until step 7 (`fields_unsupported`), so no production collection can define them yet and Studio looks unchanged there. + +- **New read-only route `GET /api/sites/:siteId/entries/groups?ids=a,b`** (`content:read`, in the playground's allow-list): each translation group's members in the site, any status, as `{groupId, id, title, collection, status, locale}` cards (core-ts `getGroupMembers` and `referenceCard`); a group with no member left answers `[]`. One to 100 well-formed group ids (the per-entry reference cap), 400 otherwise, 404 for an unknown site. Studio reads the chosen targets' titles and states through it. +- **`fieldLabel`, `fieldHelp` and `optionLabel`** (`@cloudhal/core-ts/fields`) also take a repeater's sub-field definitions. +- **Studio** (not published): the entry editor's reference fields (`ReferenceList` and `EntryPicker` over `GET …/entries/pick`), repeaters (`RepeaterField`, focus after add and remove, an undo toast, item errors by path), and a "Used by" card from `GET …/entries/:id/references`, with the new `@cloudhal/ui` blocks `FieldControl`, `RepeaterField`, `ReferenceList`, `EntryPicker` and `UsedByList`. They load with the lazy fields chunk; the initial bundle is unchanged. diff --git a/apps/studio/bundle-budget.ts b/apps/studio/bundle-budget.ts index 70dcaa4c..82c1b823 100644 --- a/apps/studio/bundle-budget.ts +++ b/apps/studio/bundle-budget.ts @@ -81,10 +81,12 @@ export const LAZY_ONLY: readonly RegExp[] = [ /[\\/]apps[\\/]studio[\\/]src[\\/]lib[\\/](taxonomy|seo|activity)\.ts$/, // Typed custom fields (RFC-0041 §7): the entry form, its zone helpers, the builder, the operation // dialogs and the field model load with a collection that has fields, never with the shell. - /[\\/]packages[\\/]ui[\\/]src[\\/]blocks[\\/](EntryFieldsForm|entry-fields-model|zoned-time)\.tsx?$/, + // RFC-0045 §5 adds the reference and repeater blocks, the picker, "Used by", their client and + // helpers: they load with the same chunk (+0 kB for the shell). + /[\\/]packages[\\/]ui[\\/]src[\\/]blocks[\\/](EntryFieldsForm|EntryRepeater|FieldControl|RepeaterField|ReferenceList|EntryPicker|UsedByList|entry-fields-model|zoned-time)\.tsx?$/, /[\\/]packages[\\/]core-ts[\\/]src[\\/]fields[\\/]/, /[\\/]apps[\\/]studio[\\/]src[\\/]components[\\/]fields[\\/]/, - /[\\/]apps[\\/]studio[\\/]src[\\/]lib[\\/](fields|field-form)\.ts$/, + /[\\/]apps[\\/]studio[\\/]src[\\/]lib[\\/](fields|field-form|field-references|api[\\/]references)\.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`). diff --git a/apps/studio/src/components/EntryFieldCards.tsx b/apps/studio/src/components/EntryFieldCards.tsx new file mode 100644 index 00000000..162ac442 --- /dev/null +++ b/apps/studio/src/components/EntryFieldCards.tsx @@ -0,0 +1,67 @@ +import type { ContentEntry, Site } from "@cloudhal/core-contracts"; +import { lazy, Suspense } from "react"; + +// RFC-0041 §7: the typed fields form loads only for collections that define fields. +const EntryFieldsCard = lazy(() => + import("./fields/EntryFieldsCard.tsx").then((m) => ({ default: m.EntryFieldsCard })), +); +// RFC-0045 §5: "Used by" loads only where some field can reference this collection. +const UsedByCard = lazy(() => + import("./fields/UsedByCard.tsx").then((m) => ({ default: m.UsedByCard })), +); + +/** True when some field of the site, or a repeater's sub-field, can reference `collection`. */ +function referenced(site: Site, collection: string): boolean { + return Object.values(site.collections ?? {}).some((settings) => + (settings?.fields ?? []).some( + (def) => + (def.type === "reference" && def.collections.includes(collection)) || + (def.type === "repeater" && + def.fields.some((s) => s.type === "reference" && s.collections.includes(collection))), + ), + ); +} + +/** + * The entry editor's field cards, each a lazy chunk of its own: "Fields" for a collection with + * definitions (RFC-0041), and "Used by" when a reference can point at this collection (RFC-0045). + * A site without either loads neither. + */ +export function EntryFieldCards({ + siteId, + site, + entry, + onChange, + error, + disabled, +}: { + siteId: string; + site: Site | undefined; + entry: ContentEntry; + onChange: (fields: Record) => void; + error: unknown; + disabled: boolean; +}) { + if (!site) return null; + return ( + <> + {site.collections?.[entry.collection]?.fields && ( + + + + )} + {referenced(site, entry.collection) && ( + + + + )} + + ); +} diff --git a/apps/studio/src/components/fields/EntryFieldsCard.tsx b/apps/studio/src/components/fields/EntryFieldsCard.tsx index 36e6eefd..d19d727f 100644 --- a/apps/studio/src/components/fields/EntryFieldsCard.tsx +++ b/apps/studio/src/components/fields/EntryFieldsCard.tsx @@ -1,4 +1,5 @@ import type { ContentEntry, FieldDefinition, Site } from "@cloudhal/core-contracts"; +import { memberGroup } from "@cloudhal/core-ts/fields"; import { EntryFieldsForm, type EntryFieldsFormLabels, @@ -12,12 +13,16 @@ import { useMemo } from "react"; import { type TFunction, useI18n } from "../../i18n/index.tsx"; import { api } from "../../lib/api.ts"; import { entryHref } from "../../lib/entry-href.ts"; -import { fieldIssues, issuesByField } from "../../lib/fields.ts"; +import { chooseMember, issuesByPath, referenceIdsIn } from "../../lib/field-references.ts"; +import { fieldIssues } from "../../lib/fields.ts"; import { languageName } from "../../lib/languages.ts"; import { zoneLabel } from "../../lib/schedules.ts"; import { primaryHostname } from "../../lib/site-settings.ts"; import { BodyEditor } from "../BodyEditor.tsx"; +import { useToast } from "../Toast.ts"; import { FieldImage } from "./FieldImage.tsx"; +import { FieldReference } from "./FieldReference.tsx"; +import { useGroupCards } from "./useGroupCards.ts"; type FT = TFunction<"fields">; @@ -27,6 +32,8 @@ const ISSUE_CODES = [ "field_not_localized", "required_field", "too_large", + "unknown_entry", + "too_many_references", ] as const; type IssueCode = (typeof ISSUE_CODES)[number]; const isIssueCode = (code: string): code is IssueCode => @@ -50,6 +57,26 @@ export function fieldHint(t: FT, def: FieldDefinition): string | undefined { return parts.length ? parts.join(" ") : undefined; } +/** A repeater's words (RFC-0045 §5). */ +function repeaterLabels(t: FT): EntryFieldsFormLabels["repeater"] { + const r = "fields.repeater" as const; + return { + add: t(`${r}.add`), + count: (count, max) => t(`${r}.count`, { count, max }), + empty: t(`${r}.empty`), + moveUp: (index) => t(`${r}.moveUp`, { index }), + moveDown: (index) => t(`${r}.moveDown`, { index }), + actions: (index) => t(`${r}.actions`, { index }), + menuMoveUp: t(`${r}.menuMoveUp`), + menuMoveDown: t(`${r}.menuMoveDown`), + duplicate: t(`${r}.duplicate`), + remove: t(`${r}.remove`), + moved: (from, to) => t(`${r}.moved`, { from, to }), + removed: (index) => t(`${r}.removed`, { index }), + invalidItems: (count) => t(`${r}.invalidItems`, { count }), + }; +} + /** The form's words, in the Studio language. */ function useFormLabels(t: FT, originalLocale: string | undefined, locale: string) { return useMemo(() => { @@ -66,6 +93,11 @@ function useFormLabels(t: FT, originalLocale: string | undefined, locale: string noValue: t("fields.editor.noValue"), clear: t("fields.editor.clear"), originalImage: t("fields.editor.originalImage"), + sharedList: t("fields.editor.sharedList"), + items: (count) => t("fields.editor.originalItems", { count }), + itemLegend: (index, name) => + t("fields.editor.itemLegend", { index, named: name ? "yes" : "no", name: name ?? "" }), + repeater: repeaterLabels(t), date: { date: t(`${d}.date`), pickDate: t(`${d}.pickDate`), @@ -119,15 +151,31 @@ export function EntryFieldsCard({ disabled: boolean; }) { const { t, locale } = useI18n("fields", "bodyEditor", "media"); + const toast = useToast(); const definitions = site.collections?.[entry.collection]?.fields ?? []; const original = useOriginal(siteId, entry); const labels = useFormLabels(t, original.data?.locale, locale); const errors = useMemo(() => { const out: Record = {}; - for (const [key, issue] of Object.entries(issuesByField(fieldIssues(error)))) - out[key] = isIssueCode(issue.code) ? t(`fields.issues.${issue.code}`) : issue.message; + for (const [path, issue] of Object.entries(issuesByPath(fieldIssues(error)))) + out[path] = isIssueCode(issue.code) ? t(`fields.issues.${issue.code}`) : issue.message; return out; }, [error, t]); + // RFC-0045: the members of every target the entry and its original name. + const ids = useMemo( + () => [ + ...referenceIdsIn(definitions, entry.fields), + ...referenceIdsIn(definitions, original.data?.fields), + ], + [definitions, entry.fields, original.data?.fields], + ); + const groups = useGroupCards(siteId, ids); + const titleOf = (groupId: string) => { + const members = groups.cards.get(groupId); + const collections = members?.map((m) => m.collection) ?? []; + const options = { locale: entry.locale, defaultLocale: site.defaultLocale, collections }; + return members ? chooseMember(members, options)?.title : undefined; + }; const from: EntryFieldsOriginal | undefined = original.data ? { values: original.data.fields, @@ -150,9 +198,31 @@ export function EntryFieldsCard({ zoneLabel={(zone) => zoneLabel(zone)} labels={labels} disabled={disabled} + multilingual={site.locales.length > 1} + referenceTitle={titleOf} renderImage={(slot) => ( )} + renderReference={(slot) => ( + + )} + onItemRemoved={({ field, legend, undo }) => + toast.notice({ + id: `field-item-removed-${field}`, + title: t("fields.repeater.removedToast", { item: legend }), + tone: "info", + timeout: 10_000, + actions: [{ label: t("fields.repeater.undo"), onClick: undo }], + }) + } renderRichText={(slot) => (
diff --git a/apps/studio/src/components/fields/FieldReference.tsx b/apps/studio/src/components/fields/FieldReference.tsx new file mode 100644 index 00000000..0ce939d9 --- /dev/null +++ b/apps/studio/src/components/fields/FieldReference.tsx @@ -0,0 +1,207 @@ +import type { Site } from "@cloudhal/core-contracts"; +import { + Button, + EntryPicker, + type EntryPickerItem, + type EntryPickerLabels, + type FieldReferenceSlot, + ReferenceList, + type ReferenceListItem, + type ReferenceListLabels, + Text, +} from "@cloudhal/ui"; +import { PencilSimple, Plus } from "@phosphor-icons/react"; +import { useMemo, useRef } from "react"; +import { formatList, type TFunction, useI18n } from "../../i18n/index.tsx"; +import { type ReferenceCard, referencesApi } from "../../lib/api/references.ts"; +import { collectionName } from "../../lib/collections.ts"; +import { entryHref } from "../../lib/entry-href.ts"; +import { chooseMember } from "../../lib/field-references.ts"; +import { languageName } from "../../lib/languages.ts"; + +type FT = TFunction<"fields">; + +/** The targets' members the form knows (any status); `[]` for a group with none left. */ +export type GroupCards = ReadonlyMap; + +function useListLabels(t: FT): ReferenceListLabels { + return useMemo( + () => ({ + draft: t("status.draft"), + review: t("status.review"), + archived: t("status.archived"), + notFound: t("fields.reference.notFound"), + notShown: t("fields.reference.notShown"), + notFoundHint: t("fields.reference.notFoundHint"), + moveUp: (title) => t("fields.reference.moveUp", { title }), + moveDown: (title) => t("fields.reference.moveDown", { title }), + actions: (title) => t("fields.reference.actions", { title }), + open: t("fields.reference.open"), + menuMoveUp: t("fields.reference.menuMoveUp"), + menuMoveDown: t("fields.reference.menuMoveDown"), + remove: t("fields.reference.remove"), + loading: t("common.loading"), + }), + [t], + ); +} + +function usePickerLabels(t: FT, multiple: boolean, collections: string): EntryPickerLabels { + return useMemo(() => { + const p = "fields.reference.picker" as const; + return { + title: t(`${p}.title`, { multiple: multiple ? "yes" : "no" }), + description: t(`${p}.description`, { collections }), + search: t(`${p}.search`), + results: (count) => t(`${p}.results`, { count }), + searching: t(`${p}.searching`), + noResults: t(`${p}.noResults`), + failed: t(`${p}.failed`), + more: t(`${p}.more`), + keepSearching: t(`${p}.keepSearching`), + truncated: t(`${p}.truncated`), + remaining: (count) => t(`${p}.remaining`, { count }), + unavailable: t(`${p}.unavailable`), + status: { + draft: t("status.draft"), + review: t("status.review"), + published: t("status.published"), + archived: t("status.archived"), + }, + add: (count) => t(`${p}.add`, { count }), + cancel: t("common.cancel"), + close: t("common.close"), + }; + }, [t, multiple, collections]); +} + +/** + * A reference field in the entry editor (RFC-0045 §5): the chosen targets as `ReferenceList` + * (in stored order, the member in the entry's language, its state, "Not found" for a deleted + * one) and `EntryPicker` over the picker route (`GET …/entries/pick`, keyset cursor and + * `truncated`). The entry's own group and the chosen ones can't be picked again. + */ +export function FieldReference({ + siteId, + site, + entryLocale, + ownGroup, + slot, + cards, + onPicked, +}: { + siteId: string; + site: Site; + entryLocale: string; + ownGroup: string; + slot: FieldReferenceSlot; + cards: GroupCards; + /** Cards the picker returned, so a new target shows at once. */ + onPicked: (cards: ReferenceCard[]) => void; +}) { + const { t, locale } = useI18n("fields"); + const { def, value, multiple, max } = slot; + const name = (collection: string) => collectionName(site, collection, locale, t("nav.articles")); + const collections = formatList(locale, def.collections.map(name)); + const listLabels = useListLabels(t); + const pickerLabels = usePickerLabels(t, multiple, collections); + const options = { + locale: entryLocale, + defaultLocale: site.defaultLocale, + collections: def.collections, + }; + const items: ReferenceListItem[] = value.map((id) => { + const members = cards.get(id); + if (members === undefined) return { id }; + const member = chooseMember(members, options); + if (!member) return { id, status: "missing" }; + return { + id, + title: member.title, + status: member.status, + href: entryHref(siteId, member.id, member.collection), + meta: t("fields.reference.meta", { + collection: name(member.collection), + language: languageName(locale, member.locale), + }), + }; + }); + const set = (next: string[]) => slot.onChange(next); + const remaining = multiple ? max - value.length : 1; + const toItem = (card: ReferenceCard): EntryPickerItem => ({ + id: card.groupId, + title: card.title, + collection: name(card.collection), + status: card.status, + locale: languageName(locale, card.locale), + }); + // The picker's cards by group, so a new target shows before the groups' query answers. + const found = useRef(new Map()).current; + return ( +
+ {items.length > 0 && ( + 1} + readOnly={slot.readOnly} + onMove={(i, by) => { + const next = [...value]; + const j = i + by; + [next[i], next[j]] = [next[j] as string, next[i] as string]; + set(next); + }} + onRemove={(i) => set(value.filter((_, n) => n !== i))} + labels={listLabels} + /> + )} + {!slot.readOnly && ( + + : } + disabled={remaining <= 0} + > + {multiple + ? t("fields.reference.add") + : value.length + ? t("fields.reference.change") + : t("fields.reference.choose")} + + } + multiple={multiple} + remaining={Math.max(remaining, 0)} + unavailable={new Set([...value, ownGroup])} + search={async (q, cursor) => { + const page = await referencesApi.pick(siteId, { + collections: def.collections, + q, + ...(cursor ? { cursor } : {}), + }); + for (const card of page.entries) found.set(card.groupId, card); + return { + items: page.entries.map(toItem), + cursor: page.cursor, + truncated: page.truncated === true, + }; + }} + onPick={(picked) => { + const ids = picked.map((p) => p.id); + onPicked(ids.flatMap((id) => found.get(id) ?? [])); + set(multiple ? [...value, ...ids] : ids.slice(0, 1)); + }} + labels={pickerLabels} + /> + {multiple && ( + + {t("fields.reference.full", { max })} + + )} + + )} +
+ ); +} diff --git a/apps/studio/src/components/fields/UsedByCard.tsx b/apps/studio/src/components/fields/UsedByCard.tsx new file mode 100644 index 00000000..18b4c68c --- /dev/null +++ b/apps/studio/src/components/fields/UsedByCard.tsx @@ -0,0 +1,121 @@ +import type { ContentEntry, Site } from "@cloudhal/core-contracts"; +import { + Banner, + Button, + SectionCard, + SectionEmpty, + UsedByList, + type UsedByRow, +} from "@cloudhal/ui"; +import { LinkSimple } from "@phosphor-icons/react"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { formatList, useI18n } from "../../i18n/index.tsx"; +import { type Referrer, referencesApi } from "../../lib/api/references.ts"; +import { collectionName } from "../../lib/collections.ts"; +import { entryHref } from "../../lib/entry-href.ts"; +import { referrerFieldWords } from "../../lib/field-references.ts"; +import { languageName } from "../../lib/languages.ts"; + +const PAGE = 20; + +/** + * The entry editor's "Used by" card (RFC-0045 §5): every entry whose fields reference this + * entry's translation group (`GET …/entries/:id/references`, published holders first), as + * `UsedByList` in a flush `SectionCard`, with "Show more" while pages remain. Each row says where + * the value sits and which translations show it too. Shown only when some field of the site can + * reference this collection. + */ +export function UsedByCard({ + siteId, + site, + entry, +}: { + siteId: string; + site: Site; + entry: ContentEntry; +}) { + const { t, locale } = useI18n("fields"); + const pages = useInfiniteQuery({ + queryKey: ["entry-referrers", siteId, entry.id], + queryFn: ({ pageParam }) => + referencesApi.referrers(siteId, entry.id, { limit: PAGE, offset: pageParam }), + initialPageParam: 0, + getNextPageParam: (last) => last.next, + }); + const referrers = pages.data?.pages.flatMap((p) => p.referrers) ?? []; + const row = (r: Referrer): UsedByRow => { + const words = referrerFieldWords(site, r.collection, r.field, locale); + const field = words.sub + ? t("fields.usedBy.subField", { field: words.field, sub: words.sub }) + : words.field; + return { + id: `${r.id} ${r.field}`, + title: r.title, + href: entryHref(siteId, r.id, r.collection), + detail: t("fields.usedBy.detail", { + collection: collectionName(site, r.collection, locale, t("nav.articles")), + language: languageName(locale, r.locale), + field, + }), + status: r.status, + ...(r.shownBy.length + ? { + shownBy: t("fields.usedBy.shownBy", { + languages: formatList( + locale, + r.shownBy.map((m) => languageName(locale, m.locale)), + ), + }), + } + : {}), + }; + }; + const empty = !pages.isPending && !pages.isError && referrers.length === 0; + return ( + } + flush={!empty && !pages.isError} + footer={ + pages.hasNextPage ? ( + + ) : undefined + } + > + {pages.isError ? ( + + ) : empty ? ( + } + title={t("fields.usedBy.empty")} + description={t("fields.usedBy.emptyDescription")} + /> + ) : ( + + )} + + ); +} diff --git a/apps/studio/src/components/fields/useGroupCards.ts b/apps/studio/src/components/fields/useGroupCards.ts new file mode 100644 index 00000000..78cd4b93 --- /dev/null +++ b/apps/studio/src/components/fields/useGroupCards.ts @@ -0,0 +1,45 @@ +import { FIELD_LIMITS } from "@cloudhal/core-ts/fields"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { useCallback, useMemo, useState } from "react"; +import { type ReferenceCard, referencesApi } from "../../lib/api/references.ts"; + +/** + * The members of every group the form names (RFC-0045 §5), any status, from `GET + * …/entries/groups` (100 ids a request), and the picker's cards for targets chosen since, so a new + * target shows before the answer. A group with no member left maps to `[]` ("Not found"). + */ +export function useGroupCards(siteId: string, ids: readonly string[]) { + const [picked, setPicked] = useState>(new Map()); + const wanted = useMemo(() => [...new Set(ids)].sort(), [ids]); + const query = useQuery({ + queryKey: ["entry-groups", siteId, wanted.join(",")], + queryFn: async () => { + const out: Record = {}; + for (let i = 0; i < wanted.length; i += FIELD_LIMITS.referencesPerEntry) { + const page = await referencesApi.groups( + siteId, + wanted.slice(i, i + FIELD_LIMITS.referencesPerEntry), + ); + Object.assign(out, page.groups); + } + return out; + }, + enabled: wanted.length > 0, + placeholderData: keepPreviousData, + }); + const cards = useMemo(() => { + const out = new Map(picked); + for (const [id, members] of Object.entries(query.data ?? {})) out.set(id, members); + return out; + }, [picked, query.data]); + const add = useCallback( + (more: readonly ReferenceCard[]) => + setPicked((was) => { + const next = new Map(was); + for (const card of more) next.set(card.groupId, [card]); + return next; + }), + [], + ); + return { cards, add }; +} diff --git a/apps/studio/src/i18n/generated/params.ts b/apps/studio/src/i18n/generated/params.ts index 25358e20..41e7863b 100644 --- a/apps/studio/src/i18n/generated/params.ts +++ b/apps/studio/src/i18n/generated/params.ts @@ -271,6 +271,8 @@ export interface MessageParams { "fields.editor.date.daySelected": { date: string | number }; "fields.editor.date.dayToday": { date: string | number }; "fields.editor.fromOriginal": { language: string | number }; + "fields.editor.itemLegend": { named: "yes" | "no"; index: number; name: string | number }; + "fields.editor.originalItems": { count: number }; "fields.editor.originalValue": { value: string | number }; "fields.hints.max": { max: number }; "fields.hints.maxLength": { count: number }; @@ -285,6 +287,27 @@ export interface MessageParams { "fields.operation.titles.remove_field": { name: string | number }; "fields.operation.titles.rename_field": { name: string | number }; "fields.operation.titles.set_field_localized": { name: string | number }; + "fields.reference.actions": { title: string | number }; + "fields.reference.full": { max: number }; + "fields.reference.meta": { collection: string | number; language: string | number }; + "fields.reference.moveDown": { title: string | number }; + "fields.reference.moveUp": { title: string | number }; + "fields.reference.picker.add": { count: number }; + "fields.reference.picker.description": { collections: string | number }; + "fields.reference.picker.remaining": { count: number }; + "fields.reference.picker.results": { count: number }; + "fields.reference.picker.title": { multiple: "yes" | "no" }; + "fields.repeater.actions": { index: number }; + "fields.repeater.count": { count: number; max: number }; + "fields.repeater.invalidItems": { count: number }; + "fields.repeater.moveDown": { index: number }; + "fields.repeater.moveUp": { index: number }; + "fields.repeater.moved": { from: number; to: number }; + "fields.repeater.removed": { index: number }; + "fields.repeater.removedToast": { item: string | number }; + "fields.usedBy.detail": { collection: string | number; language: string | number; field: string | number }; + "fields.usedBy.shownBy": { languages: string | number }; + "fields.usedBy.subField": { field: string | number; sub: string | number }; "forgotPassword.sent": { email: string | number }; "gating.abilities.activity.all": { roles: string | number; role: string | number }; "gating.abilities.agents.manage": { roles: string | number; role: string | number }; diff --git a/apps/studio/src/i18n/lazy/fields.ar.ts b/apps/studio/src/i18n/lazy/fields.ar.ts index 38bc932f..a1105e7f 100644 --- a/apps/studio/src/i18n/lazy/fields.ar.ts +++ b/apps/studio/src/i18n/lazy/fields.ar.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ar `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: ae52d84482f69224 +// hash: ca7b0b36086b7202 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -27,6 +27,11 @@ export const messages: NamespaceMessages<"fields"> = { noValue: "بلا قيمة", clear: "امسح", originalImage: "صورة الأصل", + sharedList: "غير مترجمة: هذه القائمة مشتركة بين كل اللغات.", + originalItems: + "{count, plural, zero {لا عناصر} one {عنصر واحد} two {عنصران} few {# عناصر} many {# عنصرًا} other {# عنصر}}", + itemLegend: + "{named, select, yes {العنصر {index, number}: {name}} no {العنصر {index, number}} other {العنصر {index, number}}}", date: { date: "التاريخ", pickDate: "اختر تاريخًا", @@ -53,6 +58,75 @@ export const messages: NamespaceMessages<"fields"> = { field_not_localized: "هذا الحقل مشترك بين كل اللغات. غيّره في الأصل.", required_field: "مطلوب للنشر. أضف قيمة، أو اترك الإدخال مسودة.", too_large: "حقول هذا الإدخال كبيرة جدًا مجتمعة.", + unknown_entry: + "لا يمكن اختيار هذا الإدخال هنا: فقد حُذف أو أُرشف أو ينتمي إلى مجموعة أخرى. اختر إدخالًا آخر.", + too_many_references: "يمكن أن يحتوي الإدخال على 100 مرجع على الأكثر في كل حقوله مجتمعة.", + }, + repeater: { + add: "أضف عنصرًا", + count: "{count, number} من {max, number}", + empty: "لا توجد عناصر بعد", + moveUp: "انقل العنصر {index, number} لأعلى", + moveDown: "انقل العنصر {index, number} لأسفل", + actions: "إجراءات العنصر {index, number}", + menuMoveUp: "انقل لأعلى", + menuMoveDown: "انقل لأسفل", + duplicate: "أنشئ نسخة مكررة", + remove: "أزل", + moved: "نُقل العنصر {from, number} إلى الموضع {to, number}", + removed: "أُزيل العنصر {index, number}", + invalidItems: + "{count, plural, zero {لا عناصر مطوية تحتاج إلى انتباه:} one {عنصر مطوي واحد يحتاج إلى انتباه:} two {عنصران مطويان يحتاجان إلى انتباه:} few {# عناصر مطوية تحتاج إلى انتباه:} many {# عنصرًا مطويًا يحتاج إلى انتباه:} other {# عنصر مطوي يحتاج إلى انتباه:}}", + removedToast: "أُزيل {item}", + undo: "تراجع", + }, + reference: { + choose: "اختر إدخالًا", + add: "أضف إدخالات", + change: "غيّر", + full: "{max, plural, zero {لا يقبل أي إدخال.} one {يقبل إدخالًا واحدًا.} two {يقبل إدخالين على الأكثر.} few {يقبل # إدخالات على الأكثر.} many {يقبل # إدخالًا على الأكثر.} other {يقبل # إدخال على الأكثر.}}", + meta: "{collection} · {language}", + notFound: "غير موجود", + notShown: "لن يظهر حتى يُنشر", + notFoundHint: "حُذف هذا الإدخال. أزله أو اختر إدخالًا آخر.", + moveUp: "انقل {title} لأعلى", + moveDown: "انقل {title} لأسفل", + actions: "إجراءات {title}", + open: "افتح", + menuMoveUp: "انقل لأعلى", + menuMoveDown: "انقل لأسفل", + remove: "أزل", + picker: { + title: "{multiple, select, yes {اختر إدخالات} no {اختر إدخالًا} other {اختر إدخالًا}}", + description: "من {collections}.", + search: "ابحث في الإدخالات", + results: + "{count, plural, =0 {لم يُعثر على إدخالات} zero {لم يُعثر على إدخالات} one {عُثر على إدخال واحد} two {عُثر على إدخالين} few {عُثر على # إدخالات} many {عُثر على # إدخالًا} other {عُثر على # إدخال}}", + searching: "جارٍ البحث…", + noResults: "لم يُعثر على إدخالات", + failed: "لم يكتمل البحث. أعد المحاولة.", + more: "اعرض المزيد", + keepSearching: "تابع البحث", + truncated: "لم يشمل البحث كل الإدخالات بعد.", + remaining: + "{count, plural, =0 {لا يمكنك اختيار المزيد} zero {لا يمكنك اختيار المزيد} one {يمكنك اختيار إدخال واحد آخر} two {يمكنك اختيار إدخالين آخرين} few {يمكنك اختيار # إدخالات أخرى} many {يمكنك اختيار # إدخالًا آخر} other {يمكنك اختيار # إدخال آخر}}", + unavailable: "مختار بالفعل", + add: "{count, plural, =0 {أضف} zero {أضف} one {أضف إدخالًا واحدًا} two {أضف إدخالين} few {أضف # إدخالات} many {أضف # إدخالًا} other {أضف # إدخال}}", + }, + }, + usedBy: { + title: "مستخدَم في", + description: "الإدخالات التي تشير حقولها إلى هذا الإدخال، والمنشورة أولًا.", + empty: "لا يشير أي إدخال إلى هذا الإدخال بعد", + emptyDescription: "تظهر هنا الإدخالات التي تختاره في حقل مرجع.", + entry: "الإدخال", + status: "الحالة", + caption: "الإدخالات التي تشير إلى هذا الإدخال", + detail: "{collection} · {language} · {field}", + subField: "{field}: {sub}", + shownBy: "يظهر أيضًا في {languages}", + more: "اعرض المزيد", + failed: "تعذّر تحميل الإدخالات التي تشير إلى هذا الإدخال.", }, image: { empty: "لا توجد صورة", diff --git a/apps/studio/src/i18n/lazy/fields.de.ts b/apps/studio/src/i18n/lazy/fields.de.ts index 61abce18..bc55b2f8 100644 --- a/apps/studio/src/i18n/lazy/fields.de.ts +++ b/apps/studio/src/i18n/lazy/fields.de.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): de `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: a95de246e7b0f658 +// hash: a92d06b266d462cc import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -27,6 +27,10 @@ export const messages: NamespaceMessages<"fields"> = { noValue: "Kein Wert", clear: "Leeren", originalImage: "Das Bild des Originals", + sharedList: "Nicht übersetzt: Diese Liste gilt für alle Sprachen.", + originalItems: "{count, plural, one {# Element} other {# Elemente}}", + itemLegend: + "{named, select, yes {Element {index, number}: {name}} no {Element {index, number}} other {Element {index, number}}}", date: { date: "Datum", pickDate: "Datum wählen", @@ -53,6 +57,77 @@ export const messages: NamespaceMessages<"fields"> = { required_field: "Zum Veröffentlichen nötig. Gib einen Wert ein oder lass den Eintrag als Entwurf.", too_large: "Die Felder dieses Eintrags sind zusammen zu groß.", + unknown_entry: + "Dieser Eintrag kann hier nicht gewählt werden: Er wurde gelöscht, archiviert oder gehört zu einer anderen Sammlung. Wähl einen anderen.", + too_many_references: + "Ein Eintrag kann über alle Felder hinweg höchstens 100 Verweise enthalten.", + }, + repeater: { + add: "Element hinzufügen", + count: "{count, number} von {max, number}", + empty: "Noch keine Elemente", + moveUp: "Element {index, number} nach oben verschieben", + moveDown: "Element {index, number} nach unten verschieben", + actions: "Aktionen für Element {index, number}", + menuMoveUp: "Nach oben", + menuMoveDown: "Nach unten", + duplicate: "Duplizieren", + remove: "Entfernen", + moved: "Element {from, number} an Position {to, number} verschoben", + removed: "Element {index, number} entfernt", + invalidItems: + "{count, plural, one {# eingeklapptes Element braucht deine Aufmerksamkeit:} other {# eingeklappte Elemente brauchen deine Aufmerksamkeit:}}", + removedToast: "{item} entfernt", + undo: "Rückgängig", + }, + reference: { + choose: "Eintrag wählen", + add: "Einträge hinzufügen", + change: "Ändern", + full: "{max, plural, one {Enthält # Eintrag.} other {Enthält bis zu # Einträge.}}", + meta: "{collection} · {language}", + notFound: "Nicht gefunden", + notShown: "Erscheint erst nach dem Veröffentlichen", + notFoundHint: "Dieser Eintrag wurde gelöscht. Entferne ihn oder wähl einen anderen.", + moveUp: "{title} nach oben verschieben", + moveDown: "{title} nach unten verschieben", + actions: "Aktionen für {title}", + open: "Öffnen", + menuMoveUp: "Nach oben", + menuMoveDown: "Nach unten", + remove: "Entfernen", + picker: { + title: + "{multiple, select, yes {Einträge wählen} no {Eintrag wählen} other {Eintrag wählen}}", + description: "Aus {collections}.", + search: "Einträge durchsuchen", + results: + "{count, plural, =0 {Keine Einträge gefunden} one {# Eintrag gefunden} other {# Einträge gefunden}}", + searching: "Wird gesucht…", + noResults: "Keine Einträge gefunden", + failed: "Die Suche wurde nicht abgeschlossen. Versuch es noch einmal.", + more: "Mehr anzeigen", + keepSearching: "Weitersuchen", + truncated: "Noch nicht alle Einträge wurden durchsucht.", + remaining: + "{count, plural, =0 {Du kannst keine weiteren wählen} one {Du kannst noch # wählen} other {Du kannst noch # wählen}}", + unavailable: "Bereits gewählt", + add: "{count, plural, =0 {Hinzufügen} one {# Eintrag hinzufügen} other {# Einträge hinzufügen}}", + }, + }, + usedBy: { + title: "Verwendet von", + description: "Einträge, deren Felder auf diesen verweisen, veröffentlichte zuerst.", + empty: "Noch kein Eintrag verweist auf diesen", + emptyDescription: "Einträge, die ihn in einem Verweisfeld wählen, erscheinen hier.", + entry: "Eintrag", + status: "Status", + caption: "Einträge, die auf diesen verweisen", + detail: "{collection} · {language} · {field}", + subField: "{field}: {sub}", + shownBy: "Auch angezeigt in {languages}", + more: "Mehr anzeigen", + failed: "Die Einträge, die auf diesen verweisen, konnten nicht geladen werden.", }, image: { empty: "Kein Bild", diff --git a/apps/studio/src/i18n/lazy/fields.en.ts b/apps/studio/src/i18n/lazy/fields.en.ts index 807733a4..4254231a 100644 --- a/apps/studio/src/i18n/lazy/fields.en.ts +++ b/apps/studio/src/i18n/lazy/fields.en.ts @@ -23,6 +23,10 @@ export const messages = { noValue: "No value", clear: "Clear", originalImage: "The original's image", + sharedList: "Not translated: this list is shared by every language.", + originalItems: "{count, plural, one {# item} other {# items}}", + itemLegend: + "{named, select, yes {Item {index, number}: {name}} no {Item {index, number}} other {Item {index, number}}}", date: { date: "Date", pickDate: "Pick a date", @@ -48,6 +52,76 @@ export const messages = { field_not_localized: "This field is shared by every language. Change it on the original.", required_field: "Needed to publish. Add a value, or keep the entry as a draft.", too_large: "The fields of this entry are too large together.", + unknown_entry: + "This entry can't be chosen here: it was deleted, archived or is in another collection. Choose another.", + too_many_references: "An entry can hold at most 100 references across its fields.", + }, + repeater: { + add: "Add item", + count: "{count, number} of {max, number}", + empty: "No items yet", + moveUp: "Move item {index, number} up", + moveDown: "Move item {index, number} down", + actions: "Actions for item {index, number}", + menuMoveUp: "Move up", + menuMoveDown: "Move down", + duplicate: "Duplicate", + remove: "Remove", + moved: "Item {from, number} moved to position {to, number}", + removed: "Item {index, number} removed", + invalidItems: + "{count, plural, one {# collapsed item needs attention:} other {# collapsed items need attention:}}", + removedToast: "{item} removed", + undo: "Undo", + }, + reference: { + choose: "Choose entry", + add: "Add entries", + change: "Change", + full: "{max, plural, one {Holds # entry.} other {Holds up to # entries.}}", + meta: "{collection} · {language}", + notFound: "Not found", + notShown: "Will not show until published", + notFoundHint: "This entry was deleted. Remove it or choose another.", + moveUp: "Move {title} up", + moveDown: "Move {title} down", + actions: "Actions for {title}", + open: "Open", + menuMoveUp: "Move up", + menuMoveDown: "Move down", + remove: "Remove", + picker: { + title: + "{multiple, select, yes {Choose entries} no {Choose an entry} other {Choose an entry}}", + description: "From {collections}.", + search: "Search entries", + results: + "{count, plural, =0 {No entries found} one {# entry found} other {# entries found}}", + searching: "Searching…", + noResults: "No entries found", + failed: "The search didn't finish. Try again.", + more: "Show more", + keepSearching: "Keep searching", + truncated: "Not every entry was searched yet.", + remaining: + "{count, plural, =0 {You can't choose more} one {You can choose # more} other {You can choose # more}}", + unavailable: "Already chosen", + add: "{count, plural, =0 {Add} one {Add # entry} other {Add # entries}}", + }, + }, + usedBy: { + title: "Used by", + description: "Entries whose fields reference this one, published first.", + empty: "No entry references this one yet", + emptyDescription: "Entries that choose it in a reference field show up here.", + entry: "Entry", + status: "Status", + caption: "Entries that reference this one", + detail: "{collection} · {language} · {field}", + subField: "{field}: {sub}", + shownBy: "Also shown in {languages}", + more: "Show more", + failed: "The entries that reference this one couldn't be loaded.", }, image: { empty: "No image", diff --git a/apps/studio/src/i18n/lazy/fields.es.ts b/apps/studio/src/i18n/lazy/fields.es.ts index 327e73e2..c56b32d2 100644 --- a/apps/studio/src/i18n/lazy/fields.es.ts +++ b/apps/studio/src/i18n/lazy/fields.es.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): es `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 751b5c912daf3e71 +// hash: dfdce65db2440f39 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -27,6 +27,10 @@ export const messages: NamespaceMessages<"fields"> = { noValue: "Sin valor", clear: "Vaciar", originalImage: "La imagen del original", + sharedList: "Sin traducir: esta lista es común a todos los idiomas.", + originalItems: "{count, plural, one {# elemento} many {# de elementos} other {# elementos}}", + itemLegend: + "{named, select, yes {Elemento {index, number}: {name}} no {Elemento {index, number}} other {Elemento {index, number}}}", date: { date: "Fecha", pickDate: "Elegir una fecha", @@ -53,6 +57,77 @@ export const messages: NamespaceMessages<"fields"> = { field_not_localized: "Este campo es común a todos los idiomas. Cámbialo en el original.", required_field: "Necesario para publicar. Añade un valor o deja la entrada como borrador.", too_large: "Los campos de esta entrada son demasiado grandes en conjunto.", + unknown_entry: + "Esta entrada no se puede elegir aquí: se eliminó, se archivó o está en otra colección. Elige otra.", + too_many_references: + "Una entrada puede tener como máximo 100 referencias entre todos sus campos.", + }, + repeater: { + add: "Añadir elemento", + count: "{count, number} de {max, number}", + empty: "Aún no hay elementos", + moveUp: "Subir el elemento {index, number}", + moveDown: "Bajar el elemento {index, number}", + actions: "Acciones del elemento {index, number}", + menuMoveUp: "Subir", + menuMoveDown: "Bajar", + duplicate: "Duplicar", + remove: "Quitar", + moved: "Elemento {from, number} movido a la posición {to, number}", + removed: "Elemento {index, number} quitado", + invalidItems: + "{count, plural, one {# elemento contraído necesita atención:} many {# de elementos contraídos necesitan atención:} other {# elementos contraídos necesitan atención:}}", + removedToast: "{item} quitado", + undo: "Deshacer", + }, + reference: { + choose: "Elegir entrada", + add: "Añadir entradas", + change: "Cambiar", + full: "{max, plural, one {Admite # entrada.} many {Admite hasta # de entradas.} other {Admite hasta # entradas.}}", + meta: "{collection} · {language}", + notFound: "No encontrada", + notShown: "No se mostrará hasta que se publique", + notFoundHint: "Esta entrada se eliminó. Quítala o elige otra.", + moveUp: "Subir {title}", + moveDown: "Bajar {title}", + actions: "Acciones de {title}", + open: "Abrir", + menuMoveUp: "Subir", + menuMoveDown: "Bajar", + remove: "Quitar", + picker: { + title: + "{multiple, select, yes {Elegir entradas} no {Elegir una entrada} other {Elegir una entrada}}", + description: "De {collections}.", + search: "Buscar entradas", + results: + "{count, plural, =0 {No se encontraron entradas} one {# entrada encontrada} many {# de entradas encontradas} other {# entradas encontradas}}", + searching: "Buscando…", + noResults: "No se encontraron entradas", + failed: "La búsqueda no terminó. Inténtalo de nuevo.", + more: "Mostrar más", + keepSearching: "Seguir buscando", + truncated: "Aún no se han buscado todas las entradas.", + remaining: + "{count, plural, =0 {No puedes elegir más} one {Puedes elegir # más} many {Puedes elegir # más} other {Puedes elegir # más}}", + unavailable: "Ya elegida", + add: "{count, plural, =0 {Añadir} one {Añadir # entrada} many {Añadir # de entradas} other {Añadir # entradas}}", + }, + }, + usedBy: { + title: "Usada por", + description: "Entradas cuyos campos hacen referencia a esta, primero las publicadas.", + empty: "Ninguna entrada hace referencia a esta todavía", + emptyDescription: "Aquí aparecen las entradas que la eligen en un campo de referencia.", + entry: "Entrada", + status: "Estado", + caption: "Entradas que hacen referencia a esta", + detail: "{collection} · {language} · {field}", + subField: "{field}: {sub}", + shownBy: "También se muestra en {languages}", + more: "Mostrar más", + failed: "No se pudieron cargar las entradas que hacen referencia a esta.", }, image: { empty: "Sin imagen", diff --git a/apps/studio/src/i18n/lazy/fields.fa.ts b/apps/studio/src/i18n/lazy/fields.fa.ts index 6058acd3..1082cb8b 100644 --- a/apps/studio/src/i18n/lazy/fields.fa.ts +++ b/apps/studio/src/i18n/lazy/fields.fa.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): fa `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: fcc0e8a4b354aba1 +// hash: 18df3eeb78dedd95 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -27,6 +27,10 @@ export const messages: NamespaceMessages<"fields"> = { noValue: "بدون مقدار", clear: "پاک کردن", originalImage: "تصویر نسخهٔ اصلی", + sharedList: "ترجمه نمی‌شود: این فهرست بین همهٔ زبان‌ها مشترک است.", + originalItems: "{count, plural, one {# مورد} other {# مورد}}", + itemLegend: + "{named, select, yes {مورد {index, number}: {name}} no {مورد {index, number}} other {مورد {index, number}}}", date: { date: "تاریخ", pickDate: "انتخاب تاریخ", @@ -52,6 +56,76 @@ export const messages: NamespaceMessages<"fields"> = { field_not_localized: "این فیلد بین همهٔ زبان‌ها مشترک است. آن را در نسخهٔ اصلی تغییر دهید.", required_field: "لازم برای انتشار. مقداری وارد کنید یا ورودی را پیش‌نویس نگه دارید.", too_large: "فیلدهای این ورودی روی هم خیلی بزرگ‌اند.", + unknown_entry: + "این ورودی را اینجا نمی‌توان انتخاب کرد: حذف یا بایگانی شده یا در مجموعهٔ دیگری است. ورودی دیگری انتخاب کنید.", + too_many_references: "یک ورودی در همهٔ فیلدهایش روی هم حداکثر 100 ارجاع می‌تواند داشته باشد.", + }, + repeater: { + add: "افزودن مورد", + count: "{count, number} از {max, number}", + empty: "هنوز موردی نیست", + moveUp: "بردن مورد {index, number} به بالا", + moveDown: "بردن مورد {index, number} به پایین", + actions: "کارهای مورد {index, number}", + menuMoveUp: "انتقال به بالا", + menuMoveDown: "انتقال به پایین", + duplicate: "تکثیر", + remove: "حذف", + moved: "مورد {from, number} به جایگاه {to, number} منتقل شد", + removed: "مورد {index, number} حذف شد", + invalidItems: + "{count, plural, one {# مورد جمع‌شده نیاز به بررسی دارد:} other {# مورد جمع‌شده نیاز به بررسی دارد:}}", + removedToast: "{item} حذف شد", + undo: "واگرد", + }, + reference: { + choose: "انتخاب ورودی", + add: "افزودن ورودی‌ها", + change: "تغییر", + full: "{max, plural, one {فقط # ورودی می‌پذیرد.} other {تا # ورودی می‌پذیرد.}}", + meta: "{collection} · {language}", + notFound: "پیدا نشد", + notShown: "تا زمان انتشار نمایش داده نمی‌شود", + notFoundHint: "این ورودی حذف شده است. آن را بردارید یا ورودی دیگری انتخاب کنید.", + moveUp: "بردن {title} به بالا", + moveDown: "بردن {title} به پایین", + actions: "کارهای {title}", + open: "باز کردن", + menuMoveUp: "انتقال به بالا", + menuMoveDown: "انتقال به پایین", + remove: "حذف", + picker: { + title: + "{multiple, select, yes {انتخاب ورودی‌ها} no {انتخاب یک ورودی} other {انتخاب یک ورودی}}", + description: "از {collections}.", + search: "جستجوی ورودی‌ها", + results: + "{count, plural, =0 {ورودی‌ای پیدا نشد} one {# ورودی پیدا شد} other {# ورودی پیدا شد}}", + searching: "در حال جستجو…", + noResults: "ورودی‌ای پیدا نشد", + failed: "جستجو تمام نشد. دوباره تلاش کنید.", + more: "نمایش بیشتر", + keepSearching: "ادامهٔ جستجو", + truncated: "هنوز همهٔ ورودی‌ها جستجو نشده‌اند.", + remaining: + "{count, plural, =0 {بیش از این نمی‌توانید انتخاب کنید} one {# ورودی دیگر می‌توانید انتخاب کنید} other {# ورودی دیگر می‌توانید انتخاب کنید}}", + unavailable: "قبلاً انتخاب شده", + add: "{count, plural, =0 {افزودن} one {افزودن # ورودی} other {افزودن # ورودی}}", + }, + }, + usedBy: { + title: "استفاده‌شده در", + description: "ورودی‌هایی که فیلدهایشان به این ورودی ارجاع می‌دهند، اول منتشرشده‌ها.", + empty: "هنوز هیچ ورودی‌ای به این ورودی ارجاع نمی‌دهد", + emptyDescription: "ورودی‌هایی که آن را در یک فیلد ارجاع انتخاب کنند اینجا نمایش داده می‌شوند.", + entry: "ورودی", + status: "وضعیت", + caption: "ورودی‌هایی که به این ورودی ارجاع می‌دهند", + detail: "{collection} · {language} · {field}", + subField: "{field}: {sub}", + shownBy: "در {languages} هم نمایش داده می‌شود", + more: "نمایش بیشتر", + failed: "ورودی‌هایی که به این ورودی ارجاع می‌دهند بارگذاری نشدند.", }, image: { empty: "بدون تصویر", diff --git a/apps/studio/src/i18n/lazy/fields.fr.ts b/apps/studio/src/i18n/lazy/fields.fr.ts index 07062ff6..f87a9e26 100644 --- a/apps/studio/src/i18n/lazy/fields.fr.ts +++ b/apps/studio/src/i18n/lazy/fields.fr.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): fr `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: e00a0b20dbb80425 +// hash: e70d19aac2c6b99d import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -27,6 +27,10 @@ export const messages: NamespaceMessages<"fields"> = { noValue: "Aucune valeur", clear: "Effacer", originalImage: "L’image de l’original", + sharedList: "Non traduit : cette liste est commune à toutes les langues.", + originalItems: "{count, plural, one {# élément} many {# d’éléments} other {# éléments}}", + itemLegend: + "{named, select, yes {Élément {index, number} : {name}} no {Élément {index, number}} other {Élément {index, number}}}", date: { date: "Date", pickDate: "Choisir une date", @@ -54,6 +58,78 @@ export const messages: NamespaceMessages<"fields"> = { required_field: "Nécessaire pour publier. Ajoutez une valeur ou gardez l’entrée en brouillon.", too_large: "Les champs de cette entrée sont trop volumineux ensemble.", + unknown_entry: + "Cette entrée ne peut pas être choisie ici : elle a été supprimée, archivée ou appartient à une autre collection. Choisissez-en une autre.", + too_many_references: + "Une entrée peut contenir au plus 100 références, tous champs confondus.", + }, + repeater: { + add: "Ajouter un élément", + count: "{count, number} sur {max, number}", + empty: "Aucun élément pour l’instant", + moveUp: "Monter l’élément {index, number}", + moveDown: "Descendre l’élément {index, number}", + actions: "Actions pour l’élément {index, number}", + menuMoveUp: "Monter", + menuMoveDown: "Descendre", + duplicate: "Dupliquer", + remove: "Retirer", + moved: "Élément {from, number} déplacé en position {to, number}", + removed: "Élément {index, number} retiré", + invalidItems: + "{count, plural, one {# élément replié demande votre attention :} many {# d’éléments repliés demandent votre attention :} other {# éléments repliés demandent votre attention :}}", + removedToast: "{item} retiré", + undo: "Annuler", + }, + reference: { + choose: "Choisir une entrée", + add: "Ajouter des entrées", + change: "Changer", + full: "{max, plural, one {Contient # entrée.} many {Contient jusqu’à # d’entrées.} other {Contient jusqu’à # entrées.}}", + meta: "{collection} · {language}", + notFound: "Introuvable", + notShown: "Ne s’affichera pas avant sa publication", + notFoundHint: "Cette entrée a été supprimée. Retirez-la ou choisissez-en une autre.", + moveUp: "Monter {title}", + moveDown: "Descendre {title}", + actions: "Actions pour {title}", + open: "Ouvrir", + menuMoveUp: "Monter", + menuMoveDown: "Descendre", + remove: "Retirer", + picker: { + title: + "{multiple, select, yes {Choisir des entrées} no {Choisir une entrée} other {Choisir une entrée}}", + description: "Parmi {collections}.", + search: "Rechercher des entrées", + results: + "{count, plural, =0 {Aucune entrée trouvée} one {# entrée trouvée} many {# d’entrées trouvées} other {# entrées trouvées}}", + searching: "Recherche en cours…", + noResults: "Aucune entrée trouvée", + failed: "La recherche n’a pas abouti. Réessayez.", + more: "Afficher plus", + keepSearching: "Continuer la recherche", + truncated: "Toutes les entrées n’ont pas encore été parcourues.", + remaining: + "{count, plural, =0 {Vous ne pouvez plus en choisir} one {Vous pouvez encore en choisir #} many {Vous pouvez encore en choisir #} other {Vous pouvez encore en choisir #}}", + unavailable: "Déjà choisie", + add: "{count, plural, =0 {Ajouter} one {Ajouter # entrée} many {Ajouter # d’entrées} other {Ajouter # entrées}}", + }, + }, + usedBy: { + title: "Utilisée par", + description: "Les entrées dont les champs font référence à celle-ci, publiées en premier.", + empty: "Aucune entrée ne fait encore référence à celle-ci", + emptyDescription: + "Les entrées qui la choisissent dans un champ de référence apparaissent ici.", + entry: "Entrée", + status: "Statut", + caption: "Entrées qui font référence à celle-ci", + detail: "{collection} · {language} · {field}", + subField: "{field} : {sub}", + shownBy: "Également affichée en {languages}", + more: "Afficher plus", + failed: "Impossible de charger les entrées qui font référence à celle-ci.", }, image: { empty: "Aucune image", diff --git a/apps/studio/src/i18n/lazy/fields.he.ts b/apps/studio/src/i18n/lazy/fields.he.ts index 5787d9ab..df7a5e4e 100644 --- a/apps/studio/src/i18n/lazy/fields.he.ts +++ b/apps/studio/src/i18n/lazy/fields.he.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): he `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: fc077493b04fe650 +// hash: 11370bdcd190703d import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -27,6 +27,10 @@ export const messages: NamespaceMessages<"fields"> = { noValue: "אין ערך", clear: "ניקוי", originalImage: "התמונה של המקור", + sharedList: "לא מתורגם: הרשימה הזו משותפת לכל השפות.", + originalItems: "{count, plural, one {פריט אחד} two {שני פריטים} other {# פריטים}}", + itemLegend: + "{named, select, yes {פריט {index, number}: {name}} no {פריט {index, number}} other {פריט {index, number}}}", date: { date: "תאריך", pickDate: "בחירת תאריך", @@ -52,6 +56,75 @@ export const messages: NamespaceMessages<"fields"> = { field_not_localized: "השדה הזה משותף לכל השפות. שנו אותו במקור.", required_field: "נדרש לפרסום. הוסיפו ערך, או השאירו את הרשומה כטיוטה.", too_large: "השדות של הרשומה הזו גדולים מדי יחד.", + unknown_entry: + "אי אפשר לבחור כאן את הרשומה הזו: היא נמחקה, הועברה לארכיון או שייכת לאוסף אחר. בחרו רשומה אחרת.", + too_many_references: "רשומה יכולה להכיל לכל היותר 100 הפניות בכל השדות שלה יחד.", + }, + repeater: { + add: "הוספת פריט", + count: "{count, number} מתוך {max, number}", + empty: "עדיין אין פריטים", + moveUp: "הזזת פריט {index, number} למעלה", + moveDown: "הזזת פריט {index, number} למטה", + actions: "פעולות עבור פריט {index, number}", + menuMoveUp: "הזזה למעלה", + menuMoveDown: "הזזה למטה", + duplicate: "שכפול", + remove: "הסרה", + moved: "פריט {from, number} הועבר למקום {to, number}", + removed: "פריט {index, number} הוסר", + invalidItems: + "{count, plural, one {פריט מקופל אחד דורש תשומת לב:} two {שני פריטים מקופלים דורשים תשומת לב:} other {# פריטים מקופלים דורשים תשומת לב:}}", + removedToast: "{item} הוסר", + undo: "ביטול פעולה", + }, + reference: { + choose: "בחירת רשומה", + add: "הוספת רשומות", + change: "החלפה", + full: "{max, plural, one {מכיל רשומה אחת.} two {מכיל עד שתי רשומות.} other {מכיל עד # רשומות.}}", + meta: "{collection} · {language}", + notFound: "לא נמצאה", + notShown: "לא תוצג עד לפרסום", + notFoundHint: "הרשומה הזו נמחקה. הסירו אותה או בחרו רשומה אחרת.", + moveUp: "הזזת {title} למעלה", + moveDown: "הזזת {title} למטה", + actions: "פעולות עבור {title}", + open: "פתיחה", + menuMoveUp: "הזזה למעלה", + menuMoveDown: "הזזה למטה", + remove: "הסרה", + picker: { + title: "{multiple, select, yes {בחירת רשומות} no {בחירת רשומה} other {בחירת רשומה}}", + description: "מתוך {collections}.", + search: "חיפוש רשומות", + results: + "{count, plural, =0 {לא נמצאו רשומות} one {נמצאה רשומה אחת} two {נמצאו שתי רשומות} other {נמצאו # רשומות}}", + searching: "מחפשים…", + noResults: "לא נמצאו רשומות", + failed: "החיפוש לא הסתיים. נסו שוב.", + more: "הצגת עוד", + keepSearching: "המשך חיפוש", + truncated: "עדיין לא נבדקו כל הרשומות.", + remaining: + "{count, plural, =0 {אי אפשר לבחור עוד} one {אפשר לבחור עוד רשומה אחת} two {אפשר לבחור עוד שתי רשומות} other {אפשר לבחור עוד # רשומות}}", + unavailable: "כבר נבחרה", + add: "{count, plural, =0 {הוספה} one {הוספת רשומה אחת} two {הוספת שתי רשומות} other {הוספת # רשומות}}", + }, + }, + usedBy: { + title: "בשימוש אצל", + description: "רשומות שהשדות שלהן מפנים לרשומה הזו, המפורסמות קודם.", + empty: "עדיין אף רשומה לא מפנה לרשומה הזו", + emptyDescription: "רשומות שבוחרות בה בשדה הפניה יופיעו כאן.", + entry: "רשומה", + status: "סטטוס", + caption: "רשומות שמפנות לרשומה הזו", + detail: "{collection} · {language} · {field}", + subField: "{field}: {sub}", + shownBy: "מוצגת גם בשפות: {languages}", + more: "הצגת עוד", + failed: "לא ניתן היה לטעון את הרשומות שמפנות לרשומה הזו.", }, image: { empty: "אין תמונה", diff --git a/apps/studio/src/i18n/lazy/fields.hi.ts b/apps/studio/src/i18n/lazy/fields.hi.ts index 8be28b6d..6993471f 100644 --- a/apps/studio/src/i18n/lazy/fields.hi.ts +++ b/apps/studio/src/i18n/lazy/fields.hi.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): hi `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: d0752ea6dc7f4c56 +// hash: 8828b475d3304f32 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -27,6 +27,10 @@ export const messages: NamespaceMessages<"fields"> = { noValue: "कोई मान नहीं", clear: "साफ़ करें", originalImage: "मूल की इमेज", + sharedList: "अनुवाद नहीं होता: यह सूची सभी भाषाओं में साझा है।", + originalItems: "{count, plural, one {# आइटम} other {# आइटम}}", + itemLegend: + "{named, select, yes {आइटम {index, number}: {name}} no {आइटम {index, number}} other {आइटम {index, number}}}", date: { date: "तारीख़", pickDate: "तारीख़ चुनें", @@ -52,6 +56,74 @@ export const messages: NamespaceMessages<"fields"> = { field_not_localized: "यह फ़ील्ड सभी भाषाओं में साझा है। इसे मूल में बदलें।", required_field: "प्रकाशन के लिए ज़रूरी। कोई मान डालें, या एंट्री को ड्राफ़्ट रहने दें।", too_large: "इस एंट्री के फ़ील्ड मिलाकर बहुत बड़े हैं।", + unknown_entry: + "यह एंट्री यहाँ नहीं चुनी जा सकती: इसे हटा दिया गया है, आर्काइव किया गया है या यह किसी दूसरे संग्रह में है। कोई दूसरी चुनें।", + too_many_references: "एक एंट्री अपने सभी फ़ील्ड में मिलाकर अधिकतम 100 रेफ़रेंस रख सकती है।", + }, + repeater: { + add: "आइटम जोड़ें", + count: "{max, number} में से {count, number}", + empty: "अभी कोई आइटम नहीं", + moveUp: "आइटम {index, number} को ऊपर ले जाएँ", + moveDown: "आइटम {index, number} को नीचे ले जाएँ", + actions: "आइटम {index, number} के लिए कार्रवाइयाँ", + menuMoveUp: "ऊपर ले जाएँ", + menuMoveDown: "नीचे ले जाएँ", + duplicate: "डुप्लिकेट बनाएँ", + remove: "हटाएँ", + moved: "आइटम {from, number} को स्थान {to, number} पर ले जाया गया", + removed: "आइटम {index, number} हटाया गया", + invalidItems: + "{count, plural, one {# समेटे गए आइटम पर ध्यान देना ज़रूरी है:} other {# समेटे गए आइटम पर ध्यान देना ज़रूरी है:}}", + removedToast: "{item} हटाया गया", + undo: "पूर्ववत करें", + }, + reference: { + choose: "एंट्री चुनें", + add: "एंट्री जोड़ें", + change: "बदलें", + full: "{max, plural, one {इसमें # एंट्री रखी जा सकती है।} other {इसमें अधिकतम # एंट्री रखी जा सकती हैं।}}", + meta: "{collection} · {language}", + notFound: "नहीं मिली", + notShown: "प्रकाशित होने तक नहीं दिखेगी", + notFoundHint: "यह एंट्री हटा दी गई है। इसे यहाँ से हटाएँ या कोई दूसरी चुनें।", + moveUp: "{title} को ऊपर ले जाएँ", + moveDown: "{title} को नीचे ले जाएँ", + actions: "{title} के लिए कार्रवाइयाँ", + open: "खोलें", + menuMoveUp: "ऊपर ले जाएँ", + menuMoveDown: "नीचे ले जाएँ", + remove: "हटाएँ", + picker: { + title: "{multiple, select, yes {एंट्री चुनें} no {एक एंट्री चुनें} other {एक एंट्री चुनें}}", + description: "{collections} से।", + search: "एंट्री खोजें", + results: "{count, plural, =0 {कोई एंट्री नहीं मिली} one {# एंट्री मिली} other {# एंट्री मिलीं}}", + searching: "खोजा जा रहा है…", + noResults: "कोई एंट्री नहीं मिली", + failed: "खोज पूरी नहीं हुई। फिर से कोशिश करें।", + more: "और दिखाएँ", + keepSearching: "खोज जारी रखें", + truncated: "अभी सभी एंट्री नहीं खोजी गई हैं।", + remaining: + "{count, plural, =0 {आप और नहीं चुन सकते} one {आप # और चुन सकते हैं} other {आप # और चुन सकते हैं}}", + unavailable: "पहले से चुनी गई", + add: "{count, plural, =0 {जोड़ें} one {# एंट्री जोड़ें} other {# एंट्री जोड़ें}}", + }, + }, + usedBy: { + title: "इस्तेमाल करने वाली एंट्री", + description: "वे एंट्री जिनके फ़ील्ड इस एंट्री को रेफ़र करते हैं, प्रकाशित पहले।", + empty: "अभी कोई एंट्री इसे रेफ़र नहीं करती", + emptyDescription: "जो एंट्री इसे किसी रेफ़रेंस फ़ील्ड में चुनती हैं, वे यहाँ दिखती हैं।", + entry: "एंट्री", + status: "स्थिति", + caption: "इस एंट्री को रेफ़र करने वाली एंट्री", + detail: "{collection} · {language} · {field}", + subField: "{field}: {sub}", + shownBy: "{languages} में भी दिखती है", + more: "और दिखाएँ", + failed: "इस एंट्री को रेफ़र करने वाली एंट्री लोड नहीं हो सकीं।", }, image: { empty: "कोई इमेज नहीं", diff --git a/apps/studio/src/i18n/lazy/fields.id.ts b/apps/studio/src/i18n/lazy/fields.id.ts index c0985b41..1f37959f 100644 --- a/apps/studio/src/i18n/lazy/fields.id.ts +++ b/apps/studio/src/i18n/lazy/fields.id.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): id `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 69776e763225166c +// hash: a17e7d660d1ead02 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -27,6 +27,10 @@ export const messages: NamespaceMessages<"fields"> = { noValue: "Tanpa nilai", clear: "Kosongkan", originalImage: "Gambar versi asli", + sharedList: "Tidak diterjemahkan: daftar ini dipakai bersama semua bahasa.", + originalItems: "{count, plural, other {# item}}", + itemLegend: + "{named, select, yes {Item {index, number}: {name}} no {Item {index, number}} other {Item {index, number}}}", date: { date: "Tanggal", pickDate: "Pilih tanggal", @@ -52,6 +56,74 @@ export const messages: NamespaceMessages<"fields"> = { field_not_localized: "Kolom ini dipakai bersama semua bahasa. Ubah di versi asli.", required_field: "Diperlukan untuk terbit. Tambahkan nilai, atau biarkan entri sebagai draf.", too_large: "Kolom-kolom entri ini terlalu besar jika digabung.", + unknown_entry: + "Entri ini tidak bisa dipilih di sini: entri sudah dihapus, diarsipkan, atau ada di koleksi lain. Pilih yang lain.", + too_many_references: "Satu entri bisa memuat paling banyak 100 referensi di semua kolomnya.", + }, + repeater: { + add: "Tambah item", + count: "{count, number} dari {max, number}", + empty: "Belum ada item", + moveUp: "Pindahkan item {index, number} ke atas", + moveDown: "Pindahkan item {index, number} ke bawah", + actions: "Tindakan untuk item {index, number}", + menuMoveUp: "Pindah ke atas", + menuMoveDown: "Pindah ke bawah", + duplicate: "Duplikat", + remove: "Hapus", + moved: "Item {from, number} dipindahkan ke posisi {to, number}", + removed: "Item {index, number} dihapus", + invalidItems: "{count, plural, other {# item yang diciutkan perlu diperiksa:}}", + removedToast: "{item} dihapus", + undo: "Urungkan", + }, + reference: { + choose: "Pilih entri", + add: "Tambah entri", + change: "Ganti", + full: "{max, plural, other {Memuat hingga # entri.}}", + meta: "{collection} · {language}", + notFound: "Tidak ditemukan", + notShown: "Tidak tampil sampai diterbitkan", + notFoundHint: "Entri ini sudah dihapus. Hapus dari sini atau pilih yang lain.", + moveUp: "Pindahkan {title} ke atas", + moveDown: "Pindahkan {title} ke bawah", + actions: "Tindakan untuk {title}", + open: "Buka", + menuMoveUp: "Pindah ke atas", + menuMoveDown: "Pindah ke bawah", + remove: "Hapus", + picker: { + title: + "{multiple, select, yes {Pilih entri} no {Pilih satu entri} other {Pilih satu entri}}", + description: "Dari {collections}.", + search: "Cari entri", + results: "{count, plural, =0 {Tidak ada entri ditemukan} other {# entri ditemukan}}", + searching: "Mencari…", + noResults: "Tidak ada entri ditemukan", + failed: "Pencarian tidak selesai. Coba lagi.", + more: "Tampilkan lainnya", + keepSearching: "Lanjutkan pencarian", + truncated: "Belum semua entri dicari.", + remaining: + "{count, plural, =0 {Anda tidak bisa memilih lagi} other {Anda bisa memilih # lagi}}", + unavailable: "Sudah dipilih", + add: "{count, plural, =0 {Tambah} other {Tambah # entri}}", + }, + }, + usedBy: { + title: "Dipakai oleh", + description: "Entri yang kolomnya merujuk ke entri ini, yang sudah terbit lebih dulu.", + empty: "Belum ada entri yang merujuk ke entri ini", + emptyDescription: "Entri yang memilihnya di kolom referensi akan muncul di sini.", + entry: "Entri", + status: "Status", + caption: "Entri yang merujuk ke entri ini", + detail: "{collection} · {language} · {field}", + subField: "{field}: {sub}", + shownBy: "Juga tampil di {languages}", + more: "Tampilkan lainnya", + failed: "Entri yang merujuk ke entri ini tidak bisa dimuat.", }, image: { empty: "Tanpa gambar", diff --git a/apps/studio/src/i18n/lazy/fields.it.ts b/apps/studio/src/i18n/lazy/fields.it.ts index 0b7b8366..30abdfef 100644 --- a/apps/studio/src/i18n/lazy/fields.it.ts +++ b/apps/studio/src/i18n/lazy/fields.it.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): it `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: e92b8195e7396fa8 +// hash: 00d7a63bcfc9fe0b import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -27,6 +27,10 @@ export const messages: NamespaceMessages<"fields"> = { noValue: "Nessun valore", clear: "Svuota", originalImage: "L’immagine dell’originale", + sharedList: "Non tradotto: questo elenco è comune a tutte le lingue.", + originalItems: "{count, plural, one {# elemento} many {# di elementi} other {# elementi}}", + itemLegend: + "{named, select, yes {Elemento {index, number}: {name}} no {Elemento {index, number}} other {Elemento {index, number}}}", date: { date: "Data", pickDate: "Scegli una data", @@ -53,6 +57,76 @@ export const messages: NamespaceMessages<"fields"> = { field_not_localized: "Questo campo è comune a tutte le lingue. Cambialo nell’originale.", required_field: "Necessario per pubblicare. Aggiungi un valore o lascia la voce come bozza.", too_large: "I campi di questa voce sono troppo grandi nel complesso.", + unknown_entry: + "Questa voce non può essere scelta qui: è stata eliminata, archiviata o si trova in un’altra raccolta. Scegline un’altra.", + too_many_references: + "Una voce può contenere al massimo 100 riferimenti in tutti i suoi campi.", + }, + repeater: { + add: "Aggiungi elemento", + count: "{count, number} di {max, number}", + empty: "Ancora nessun elemento", + moveUp: "Sposta su l’elemento {index, number}", + moveDown: "Sposta giù l’elemento {index, number}", + actions: "Azioni per l’elemento {index, number}", + menuMoveUp: "Sposta su", + menuMoveDown: "Sposta giù", + duplicate: "Duplica", + remove: "Rimuovi", + moved: "Elemento {from, number} spostato in posizione {to, number}", + removed: "Elemento {index, number} rimosso", + invalidItems: + "{count, plural, one {# elemento compresso richiede attenzione:} many {# di elementi compressi richiedono attenzione:} other {# elementi compressi richiedono attenzione:}}", + removedToast: "{item} rimosso", + undo: "Annulla", + }, + reference: { + choose: "Scegli voce", + add: "Aggiungi voci", + change: "Cambia", + full: "{max, plural, one {Contiene # voce.} many {Contiene fino a # di voci.} other {Contiene fino a # voci.}}", + meta: "{collection} · {language}", + notFound: "Non trovata", + notShown: "Non verrà mostrata finché non sarà pubblicata", + notFoundHint: "Questa voce è stata eliminata. Rimuovila o scegline un’altra.", + moveUp: "Sposta su {title}", + moveDown: "Sposta giù {title}", + actions: "Azioni per {title}", + open: "Apri", + menuMoveUp: "Sposta su", + menuMoveDown: "Sposta giù", + remove: "Rimuovi", + picker: { + title: "{multiple, select, yes {Scegli voci} no {Scegli una voce} other {Scegli una voce}}", + description: "Da {collections}.", + search: "Cerca voci", + results: + "{count, plural, =0 {Nessuna voce trovata} one {# voce trovata} many {# di voci trovate} other {# voci trovate}}", + searching: "Ricerca in corso…", + noResults: "Nessuna voce trovata", + failed: "La ricerca non è stata completata. Riprova.", + more: "Mostra altro", + keepSearching: "Continua a cercare", + truncated: "Non tutte le voci sono state ancora cercate.", + remaining: + "{count, plural, =0 {Non puoi sceglierne altre} one {Puoi sceglierne ancora #} many {Puoi sceglierne ancora #} other {Puoi sceglierne ancora #}}", + unavailable: "Già scelta", + add: "{count, plural, =0 {Aggiungi} one {Aggiungi # voce} many {Aggiungi # di voci} other {Aggiungi # voci}}", + }, + }, + usedBy: { + title: "Usata da", + description: "Voci i cui campi fanno riferimento a questa, prima quelle pubblicate.", + empty: "Ancora nessuna voce fa riferimento a questa", + emptyDescription: "Le voci che la scelgono in un campo di riferimento compaiono qui.", + entry: "Voce", + status: "Stato", + caption: "Voci che fanno riferimento a questa", + detail: "{collection} · {language} · {field}", + subField: "{field}: {sub}", + shownBy: "Mostrata anche in {languages}", + more: "Mostra altro", + failed: "Non è stato possibile caricare le voci che fanno riferimento a questa.", }, image: { empty: "Nessuna immagine", diff --git a/apps/studio/src/i18n/lazy/fields.ja.ts b/apps/studio/src/i18n/lazy/fields.ja.ts index f2659b88..2e68795b 100644 --- a/apps/studio/src/i18n/lazy/fields.ja.ts +++ b/apps/studio/src/i18n/lazy/fields.ja.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ja `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 086ca768cc37713c +// hash: 78206b6fb88017a6 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -27,6 +27,10 @@ export const messages: NamespaceMessages<"fields"> = { noValue: "値なし", clear: "クリア", originalImage: "オリジナルの画像", + sharedList: "翻訳されません:このリストはすべての言語で共通です。", + originalItems: "{count, plural, other {#件の項目}}", + itemLegend: + "{named, select, yes {項目 {index, number}:{name}} no {項目 {index, number}} other {項目 {index, number}}}", date: { date: "日付", pickDate: "日付を選ぶ", @@ -52,6 +56,77 @@ export const messages: NamespaceMessages<"fields"> = { field_not_localized: "このフィールドはすべての言語で共通です。オリジナルで変更してください。", required_field: "公開に必要です。値を入力するか、エントリーを下書きのままにしてください。", too_large: "このエントリーのフィールドは合計で大きすぎます。", + unknown_entry: + "このエントリーはここでは選択できません。削除またはアーカイブされたか、別のコレクションにあります。別のエントリーを選択してください。", + too_many_references: + "1つのエントリーが持てる参照は、すべてのフィールドを合わせて最大100件です。", + }, + repeater: { + add: "項目を追加", + count: "{count, number} / {max, number}", + empty: "項目はまだありません", + moveUp: "項目 {index, number} を上へ移動", + moveDown: "項目 {index, number} を下へ移動", + actions: "項目 {index, number} の操作", + menuMoveUp: "上へ移動", + menuMoveDown: "下へ移動", + duplicate: "複製", + remove: "削除", + moved: "項目 {from, number} を位置 {to, number} に移動しました", + removed: "項目 {index, number} を削除しました", + invalidItems: "{count, plural, other {折りたたまれた#件の項目に確認が必要です:}}", + removedToast: "{item} を削除しました", + undo: "元に戻す", + }, + reference: { + choose: "エントリーを選択", + add: "エントリーを追加", + change: "変更", + full: "{max, plural, other {最大#件のエントリーを保持できます。}}", + meta: "{collection} · {language}", + notFound: "見つかりません", + notShown: "公開されるまで表示されません", + notFoundHint: + "このエントリーは削除されました。取り除くか、別のエントリーを選択してください。", + moveUp: "{title} を上へ移動", + moveDown: "{title} を下へ移動", + actions: "{title}の操作", + open: "開く", + menuMoveUp: "上へ移動", + menuMoveDown: "下へ移動", + remove: "削除", + picker: { + title: + "{multiple, select, yes {エントリーを選択} no {エントリーを1件選択} other {エントリーを1件選択}}", + description: "{collections}から選択します。", + search: "エントリーを検索", + results: + "{count, plural, =0 {エントリーが見つかりません} other {#件のエントリーが見つかりました}}", + searching: "検索中…", + noResults: "エントリーが見つかりません", + failed: "検索が完了しませんでした。もう一度お試しください。", + more: "さらに表示", + keepSearching: "検索を続ける", + truncated: "まだすべてのエントリーを検索していません。", + remaining: "{count, plural, =0 {これ以上選択できません} other {あと#件選択できます}}", + unavailable: "選択済み", + add: "{count, plural, =0 {追加} other {#件のエントリーを追加}}", + }, + }, + usedBy: { + title: "参照元", + description: + "フィールドでこのエントリーを参照しているエントリーです。公開済みのものが先に表示されます。", + empty: "このエントリーを参照しているエントリーはまだありません", + emptyDescription: "参照フィールドでこのエントリーを選択したエントリーがここに表示されます。", + entry: "エントリー", + status: "ステータス", + caption: "このエントリーを参照しているエントリー", + detail: "{collection} · {language} · {field}", + subField: "{field}:{sub}", + shownBy: "{languages}でも表示", + more: "さらに表示", + failed: "このエントリーを参照しているエントリーを読み込めませんでした。", }, image: { empty: "画像なし", diff --git a/apps/studio/src/i18n/lazy/fields.ko.ts b/apps/studio/src/i18n/lazy/fields.ko.ts index 9614e8a2..63f9f0a8 100644 --- a/apps/studio/src/i18n/lazy/fields.ko.ts +++ b/apps/studio/src/i18n/lazy/fields.ko.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ko `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: be014dc588e354eb +// hash: c734dad53b53c294 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -27,6 +27,10 @@ export const messages: NamespaceMessages<"fields"> = { noValue: "값 없음", clear: "지우기", originalImage: "원본의 이미지", + sharedList: "번역되지 않아요: 이 목록은 모든 언어에서 공유돼요.", + originalItems: "{count, plural, other {아이템 #개}}", + itemLegend: + "{named, select, yes {아이템 {index, number}: {name}} no {아이템 {index, number}} other {아이템 {index, number}}}", date: { date: "날짜", pickDate: "날짜 선택", @@ -52,6 +56,72 @@ export const messages: NamespaceMessages<"fields"> = { field_not_localized: "이 필드는 모든 언어에서 공유돼요. 원본에서 바꾸세요.", required_field: "게시에 필요해요. 값을 추가하거나 항목을 초안으로 두세요.", too_large: "이 항목의 필드가 합쳐서 너무 커요.", + unknown_entry: + "여기서는 이 항목을 선택할 수 없어요. 삭제 또는 보관되었거나 다른 컬렉션에 있어요. 다른 항목을 선택하세요.", + too_many_references: "한 항목은 모든 필드를 합쳐 참조를 최대 100개까지 가질 수 있어요.", + }, + repeater: { + add: "아이템 추가", + count: "{count, number}/{max, number}", + empty: "아직 아이템이 없어요", + moveUp: "아이템 {index, number} 위로 이동", + moveDown: "아이템 {index, number} 아래로 이동", + actions: "아이템 {index, number} 작업", + menuMoveUp: "위로 이동", + menuMoveDown: "아래로 이동", + duplicate: "복제", + remove: "제거", + moved: "아이템 {from, number}을(를) {to, number}번 위치로 옮겼어요", + removed: "아이템 {index, number}을(를) 제거했어요", + invalidItems: "{count, plural, other {접힌 아이템 #개를 확인해야 해요:}}", + removedToast: "{item} 제거됨", + undo: "실행 취소", + }, + reference: { + choose: "항목 선택", + add: "항목 추가", + change: "변경", + full: "{max, plural, other {항목을 최대 #개까지 담을 수 있어요.}}", + meta: "{collection} · {language}", + notFound: "찾을 수 없음", + notShown: "게시될 때까지 표시되지 않아요", + notFoundHint: "이 항목은 삭제되었어요. 제거하거나 다른 항목을 선택하세요.", + moveUp: "{title} 위로 이동", + moveDown: "{title} 아래로 이동", + actions: "{title} 작업", + open: "열기", + menuMoveUp: "위로 이동", + menuMoveDown: "아래로 이동", + remove: "제거", + picker: { + title: "{multiple, select, yes {항목 선택} no {항목 하나 선택} other {항목 하나 선택}}", + description: "{collections}에서 선택해요.", + search: "항목 검색", + results: "{count, plural, =0 {항목을 찾을 수 없어요} other {항목 #개를 찾았어요}}", + searching: "검색 중…", + noResults: "항목을 찾을 수 없어요", + failed: "검색을 완료하지 못했어요. 다시 시도하세요.", + more: "더 보기", + keepSearching: "계속 검색", + truncated: "아직 모든 항목을 검색하지는 않았어요.", + remaining: "{count, plural, =0 {더 선택할 수 없어요} other {#개 더 선택할 수 있어요}}", + unavailable: "이미 선택됨", + add: "{count, plural, =0 {추가} other {항목 #개 추가}}", + }, + }, + usedBy: { + title: "참조하는 항목", + description: "필드에서 이 항목을 참조하는 항목이에요. 게시된 항목이 먼저 표시돼요.", + empty: "아직 이 항목을 참조하는 항목이 없어요", + emptyDescription: "참조 필드에서 이 항목을 선택한 항목이 여기에 표시돼요.", + entry: "항목", + status: "상태", + caption: "이 항목을 참조하는 항목", + detail: "{collection} · {language} · {field}", + subField: "{field}: {sub}", + shownBy: "{languages}에도 표시됨", + more: "더 보기", + failed: "이 항목을 참조하는 항목을 불러오지 못했어요.", }, image: { empty: "이미지 없음", diff --git a/apps/studio/src/i18n/lazy/fields.nl.ts b/apps/studio/src/i18n/lazy/fields.nl.ts index 936d911c..3de745ce 100644 --- a/apps/studio/src/i18n/lazy/fields.nl.ts +++ b/apps/studio/src/i18n/lazy/fields.nl.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): nl `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 8e615d80cdeb5625 +// hash: 2042e76e14017cc1 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -27,6 +27,10 @@ export const messages: NamespaceMessages<"fields"> = { noValue: "Geen waarde", clear: "Wissen", originalImage: "De afbeelding van het origineel", + sharedList: "Niet vertaald: deze lijst geldt voor alle talen.", + originalItems: "{count, plural, one {# element} other {# elementen}}", + itemLegend: + "{named, select, yes {Element {index, number}: {name}} no {Element {index, number}} other {Element {index, number}}}", date: { date: "Datum", pickDate: "Kies een datum", @@ -52,6 +56,77 @@ export const messages: NamespaceMessages<"fields"> = { field_not_localized: "Dit veld geldt voor alle talen. Wijzig het in het origineel.", required_field: "Nodig om te publiceren. Voeg een waarde toe of laat het item een concept.", too_large: "De velden van dit item zijn samen te groot.", + unknown_entry: + "Dit item kan hier niet worden gekozen: het is verwijderd, gearchiveerd of staat in een andere collectie. Kies een ander.", + too_many_references: + "Een item kan in al zijn velden samen hoogstens 100 verwijzingen bevatten.", + }, + repeater: { + add: "Element toevoegen", + count: "{count, number} van {max, number}", + empty: "Nog geen elementen", + moveUp: "Element {index, number} omhoog verplaatsen", + moveDown: "Element {index, number} omlaag verplaatsen", + actions: "Acties voor element {index, number}", + menuMoveUp: "Omhoog", + menuMoveDown: "Omlaag", + duplicate: "Dupliceren", + remove: "Verwijderen", + moved: "Element {from, number} verplaatst naar positie {to, number}", + removed: "Element {index, number} verwijderd", + invalidItems: + "{count, plural, one {# ingeklapt element vraagt aandacht:} other {# ingeklapte elementen vragen aandacht:}}", + removedToast: "{item} verwijderd", + undo: "Ongedaan maken", + }, + reference: { + choose: "Item kiezen", + add: "Items toevoegen", + change: "Wijzigen", + full: "{max, plural, one {Bevat # item.} other {Bevat maximaal # items.}}", + meta: "{collection} · {language}", + notFound: "Niet gevonden", + notShown: "Niet zichtbaar tot het is gepubliceerd", + notFoundHint: "Dit item is verwijderd. Verwijder het hier of kies een ander.", + moveUp: "{title} omhoog verplaatsen", + moveDown: "{title} omlaag verplaatsen", + actions: "Acties voor {title}", + open: "Openen", + menuMoveUp: "Omhoog", + menuMoveDown: "Omlaag", + remove: "Verwijderen", + picker: { + title: + "{multiple, select, yes {Items kiezen} no {Een item kiezen} other {Een item kiezen}}", + description: "Uit {collections}.", + search: "Items zoeken", + results: + "{count, plural, =0 {Geen items gevonden} one {# item gevonden} other {# items gevonden}}", + searching: "Zoeken…", + noResults: "Geen items gevonden", + failed: "Het zoeken is niet afgerond. Probeer het opnieuw.", + more: "Meer tonen", + keepSearching: "Verder zoeken", + truncated: "Nog niet alle items zijn doorzocht.", + remaining: + "{count, plural, =0 {Je kunt er geen meer kiezen} one {Je kunt er nog # kiezen} other {Je kunt er nog # kiezen}}", + unavailable: "Al gekozen", + add: "{count, plural, =0 {Toevoegen} one {# item toevoegen} other {# items toevoegen}}", + }, + }, + usedBy: { + title: "Gebruikt door", + description: "Items waarvan velden naar dit item verwijzen, gepubliceerde eerst.", + empty: "Nog geen item verwijst naar dit item", + emptyDescription: "Items die het in een verwijzingsveld kiezen, verschijnen hier.", + entry: "Item", + status: "Status", + caption: "Items die naar dit item verwijzen", + detail: "{collection} · {language} · {field}", + subField: "{field}: {sub}", + shownBy: "Ook getoond in {languages}", + more: "Meer tonen", + failed: "De items die naar dit item verwijzen, konden niet worden geladen.", }, image: { empty: "Geen afbeelding", diff --git a/apps/studio/src/i18n/lazy/fields.pl.ts b/apps/studio/src/i18n/lazy/fields.pl.ts index bd69ca35..2eedba72 100644 --- a/apps/studio/src/i18n/lazy/fields.pl.ts +++ b/apps/studio/src/i18n/lazy/fields.pl.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): pl `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 52321f8448c085c4 +// hash: 0c09a8098dbc3a85 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -27,6 +27,11 @@ export const messages: NamespaceMessages<"fields"> = { noValue: "Brak wartości", clear: "Wyczyść", originalImage: "Obraz oryginału", + sharedList: "Nie podlega tłumaczeniu: ta lista jest wspólna dla wszystkich języków.", + originalItems: + "{count, plural, one {# element} few {# elementy} many {# elementów} other {# elementu}}", + itemLegend: + "{named, select, yes {Element {index, number}: {name}} no {Element {index, number}} other {Element {index, number}}}", date: { date: "Data", pickDate: "Wybierz datę", @@ -53,6 +58,75 @@ export const messages: NamespaceMessages<"fields"> = { field_not_localized: "To pole jest wspólne dla wszystkich języków. Zmień je w oryginale.", required_field: "Wymagane do publikacji. Dodaj wartość albo zostaw wpis jako szkic.", too_large: "Pola tego wpisu są razem zbyt duże.", + unknown_entry: + "Tego wpisu nie można tu wybrać: został usunięty, zarchiwizowany albo należy do innej kolekcji. Wybierz inny.", + too_many_references: "Wpis może mieć najwyżej 100 odwołań we wszystkich polach razem.", + }, + repeater: { + add: "Dodaj element", + count: "{count, number} z {max, number}", + empty: "Brak elementów", + moveUp: "Przesuń element {index, number} w górę", + moveDown: "Przesuń element {index, number} w dół", + actions: "Działania dla elementu {index, number}", + menuMoveUp: "Przesuń w górę", + menuMoveDown: "Przesuń w dół", + duplicate: "Duplikuj", + remove: "Usuń", + moved: "Element {from, number} przeniesiono na pozycję {to, number}", + removed: "Usunięto element {index, number}", + invalidItems: + "{count, plural, one {# zwinięty element wymaga uwagi:} few {# zwinięte elementy wymagają uwagi:} many {# zwiniętych elementów wymaga uwagi:} other {# zwiniętego elementu wymaga uwagi:}}", + removedToast: "Usunięto: {item}", + undo: "Cofnij", + }, + reference: { + choose: "Wybierz wpis", + add: "Dodaj wpisy", + change: "Zmień", + full: "{max, plural, one {Mieści # wpis.} few {Mieści do # wpisów.} many {Mieści do # wpisów.} other {Mieści do # wpisu.}}", + meta: "{collection} · {language}", + notFound: "Nie znaleziono", + notShown: "Niewidoczny do czasu publikacji", + notFoundHint: "Ten wpis został usunięty. Usuń go stąd albo wybierz inny.", + moveUp: "Przesuń „{title}” w górę", + moveDown: "Przesuń „{title}” w dół", + actions: "Działania dla „{title}”", + open: "Otwórz", + menuMoveUp: "Przesuń w górę", + menuMoveDown: "Przesuń w dół", + remove: "Usuń", + picker: { + title: "{multiple, select, yes {Wybierz wpisy} no {Wybierz wpis} other {Wybierz wpis}}", + description: "Źródło: {collections}.", + search: "Szukaj wpisów", + results: + "{count, plural, =0 {Nie znaleziono wpisów} one {Znaleziono # wpis} few {Znaleziono # wpisy} many {Znaleziono # wpisów} other {Znaleziono # wpisu}}", + searching: "Szukanie…", + noResults: "Nie znaleziono wpisów", + failed: "Wyszukiwanie nie zostało ukończone. Spróbuj ponownie.", + more: "Pokaż więcej", + keepSearching: "Szukaj dalej", + truncated: "Nie przeszukano jeszcze wszystkich wpisów.", + remaining: + "{count, plural, =0 {Nie możesz wybrać więcej} one {Możesz wybrać jeszcze #} few {Możesz wybrać jeszcze #} many {Możesz wybrać jeszcze #} other {Możesz wybrać jeszcze #}}", + unavailable: "Już wybrany", + add: "{count, plural, =0 {Dodaj} one {Dodaj # wpis} few {Dodaj # wpisy} many {Dodaj # wpisów} other {Dodaj # wpisu}}", + }, + }, + usedBy: { + title: "Używany przez", + description: "Wpisy, których pola odwołują się do tego wpisu, najpierw opublikowane.", + empty: "Żaden wpis jeszcze się do niego nie odwołuje", + emptyDescription: "Pojawią się tu wpisy, które wybiorą go w polu odwołania.", + entry: "Wpis", + status: "Status", + caption: "Wpisy odwołujące się do tego wpisu", + detail: "{collection} · {language} · {field}", + subField: "{field}: {sub}", + shownBy: "Widoczne także w językach: {languages}", + more: "Pokaż więcej", + failed: "Nie udało się wczytać wpisów, które się do niego odwołują.", }, image: { empty: "Brak obrazu", diff --git a/apps/studio/src/i18n/lazy/fields.pt-BR.ts b/apps/studio/src/i18n/lazy/fields.pt-BR.ts index eb448170..328a7ace 100644 --- a/apps/studio/src/i18n/lazy/fields.pt-BR.ts +++ b/apps/studio/src/i18n/lazy/fields.pt-BR.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): pt-BR `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 2519592756b8f54e +// hash: 40c8ad48542a4858 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -27,6 +27,10 @@ export const messages: NamespaceMessages<"fields"> = { noValue: "Sem valor", clear: "Limpar", originalImage: "A imagem do original", + sharedList: "Não traduzida: esta lista é comum a todos os idiomas.", + originalItems: "{count, plural, one {# item} many {# itens} other {# itens}}", + itemLegend: + "{named, select, yes {Item {index, number}: {name}} no {Item {index, number}} other {Item {index, number}}}", date: { date: "Data", pickDate: "Escolher uma data", @@ -54,6 +58,77 @@ export const messages: NamespaceMessages<"fields"> = { required_field: "Necessário para publicar. Adicione um valor ou mantenha a entrada como rascunho.", too_large: "Os campos desta entrada são grandes demais juntos.", + unknown_entry: + "Esta entrada não pode ser escolhida aqui: ela foi excluída, arquivada ou está em outra coleção. Escolha outra.", + too_many_references: + "Uma entrada pode ter no máximo 100 referências somando todos os campos.", + }, + repeater: { + add: "Adicionar item", + count: "{count, number} de {max, number}", + empty: "Nenhum item ainda", + moveUp: "Mover o item {index, number} para cima", + moveDown: "Mover o item {index, number} para baixo", + actions: "Ações do item {index, number}", + menuMoveUp: "Mover para cima", + menuMoveDown: "Mover para baixo", + duplicate: "Duplicar", + remove: "Remover", + moved: "Item {from, number} movido para a posição {to, number}", + removed: "Item {index, number} removido", + invalidItems: + "{count, plural, one {# item recolhido precisa de atenção:} many {# itens recolhidos precisam de atenção:} other {# itens recolhidos precisam de atenção:}}", + removedToast: "Removido: {item}", + undo: "Desfazer", + }, + reference: { + choose: "Escolher entrada", + add: "Adicionar entradas", + change: "Trocar", + full: "{max, plural, one {Comporta # entrada.} many {Comporta até # entradas.} other {Comporta até # entradas.}}", + meta: "{collection} · {language}", + notFound: "Não encontrada", + notShown: "Só aparece depois de publicada", + notFoundHint: "Esta entrada foi excluída. Remova-a ou escolha outra.", + moveUp: "Mover “{title}” para cima", + moveDown: "Mover “{title}” para baixo", + actions: "Ações de “{title}”", + open: "Abrir", + menuMoveUp: "Mover para cima", + menuMoveDown: "Mover para baixo", + remove: "Remover", + picker: { + title: + "{multiple, select, yes {Escolher entradas} no {Escolher uma entrada} other {Escolher uma entrada}}", + description: "Origem: {collections}.", + search: "Buscar entradas", + results: + "{count, plural, =0 {Nenhuma entrada encontrada} one {# entrada encontrada} many {# entradas encontradas} other {# entradas encontradas}}", + searching: "Buscando…", + noResults: "Nenhuma entrada encontrada", + failed: "A busca não terminou. Tente de novo.", + more: "Mostrar mais", + keepSearching: "Continuar buscando", + truncated: "Nem todas as entradas foram pesquisadas ainda.", + remaining: + "{count, plural, =0 {Você não pode escolher mais} one {Você pode escolher mais #} many {Você pode escolher mais #} other {Você pode escolher mais #}}", + unavailable: "Já escolhida", + add: "{count, plural, =0 {Adicionar} one {Adicionar # entrada} many {Adicionar # entradas} other {Adicionar # entradas}}", + }, + }, + usedBy: { + title: "Usada por", + description: "Entradas cujos campos fazem referência a esta, com as publicadas primeiro.", + empty: "Nenhuma entrada faz referência a esta ainda", + emptyDescription: "As entradas que a escolherem em um campo de referência aparecem aqui.", + entry: "Entrada", + status: "Status", + caption: "Entradas que fazem referência a esta", + detail: "{collection} · {language} · {field}", + subField: "{field}: {sub}", + shownBy: "Também exibida em: {languages}", + more: "Mostrar mais", + failed: "Não foi possível carregar as entradas que fazem referência a esta.", }, image: { empty: "Sem imagem", diff --git a/apps/studio/src/i18n/lazy/fields.ru.ts b/apps/studio/src/i18n/lazy/fields.ru.ts index 2adc224d..441e8482 100644 --- a/apps/studio/src/i18n/lazy/fields.ru.ts +++ b/apps/studio/src/i18n/lazy/fields.ru.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ru `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: ff25f1a53f06f691 +// hash: 23715b13eaf3bb1f import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -27,6 +27,11 @@ export const messages: NamespaceMessages<"fields"> = { noValue: "Нет значения", clear: "Очистить", originalImage: "Изображение оригинала", + sharedList: "Не переводится: этот список общий для всех языков.", + originalItems: + "{count, plural, one {# элемент} few {# элемента} many {# элементов} other {# элемента}}", + itemLegend: + "{named, select, yes {Элемент {index, number}: {name}} no {Элемент {index, number}} other {Элемент {index, number}}}", date: { date: "Дата", pickDate: "Выбрать дату", @@ -53,6 +58,76 @@ export const messages: NamespaceMessages<"fields"> = { field_not_localized: "Это поле общее для всех языков. Измените его в оригинале.", required_field: "Нужно для публикации. Добавьте значение или оставьте запись черновиком.", too_large: "Поля этой записи вместе слишком велики.", + unknown_entry: + "Эту запись здесь выбрать нельзя: она удалена, в архиве или в другой коллекции. Выберите другую.", + too_many_references: "Запись может содержать не более 100 ссылок во всех полях вместе.", + }, + repeater: { + add: "Добавить элемент", + count: "{count, number} из {max, number}", + empty: "Элементов пока нет", + moveUp: "Переместить элемент {index, number} вверх", + moveDown: "Переместить элемент {index, number} вниз", + actions: "Действия для элемента {index, number}", + menuMoveUp: "Переместить вверх", + menuMoveDown: "Переместить вниз", + duplicate: "Дублировать", + remove: "Удалить", + moved: "Элемент {from, number} перемещён на позицию {to, number}", + removed: "Элемент {index, number} удалён", + invalidItems: + "{count, plural, one {# свёрнутый элемент требует внимания:} few {# свёрнутых элемента требуют внимания:} many {# свёрнутых элементов требуют внимания:} other {# свёрнутого элемента требуют внимания:}}", + removedToast: "Удалено: {item}", + undo: "Отменить", + }, + reference: { + choose: "Выбрать запись", + add: "Добавить записи", + change: "Заменить", + full: "{max, plural, one {Максимум # запись.} few {Максимум # записи.} many {Максимум # записей.} other {Максимум # записи.}}", + meta: "{collection} · {language}", + notFound: "Не найдено", + notShown: "Не появится на сайте до публикации", + notFoundHint: "Эта запись удалена. Уберите её или выберите другую.", + moveUp: "Переместить «{title}» вверх", + moveDown: "Переместить «{title}» вниз", + actions: "Действия для «{title}»", + open: "Открыть", + menuMoveUp: "Переместить вверх", + menuMoveDown: "Переместить вниз", + remove: "Убрать", + picker: { + title: + "{multiple, select, yes {Выберите записи} no {Выберите запись} other {Выберите запись}}", + description: "Источник: {collections}.", + search: "Поиск записей", + results: + "{count, plural, =0 {Записи не найдены} one {Найдена # запись} few {Найдены # записи} many {Найдено # записей} other {Найдено # записи}}", + searching: "Поиск…", + noResults: "Записи не найдены", + failed: "Поиск не завершился. Повторите попытку.", + more: "Показать ещё", + keepSearching: "Продолжить поиск", + truncated: "Поиск прошёл ещё не по всем записям.", + remaining: + "{count, plural, =0 {Больше выбрать нельзя} one {Можно выбрать ещё #} few {Можно выбрать ещё #} many {Можно выбрать ещё #} other {Можно выбрать ещё #}}", + unavailable: "Уже выбрано", + add: "{count, plural, =0 {Добавить} one {Добавить # запись} few {Добавить # записи} many {Добавить # записей} other {Добавить # записи}}", + }, + }, + usedBy: { + title: "Где используется", + description: "Записи, поля которых ссылаются на эту; сначала опубликованные.", + empty: "Пока ни одна запись не ссылается на эту", + emptyDescription: "Здесь появятся записи, которые выберут её в поле-ссылке.", + entry: "Запись", + status: "Статус", + caption: "Записи, которые ссылаются на эту", + detail: "{collection} · {language} · {field}", + subField: "{field}: {sub}", + shownBy: "Также показано на языках: {languages}", + more: "Показать ещё", + failed: "Не удалось загрузить записи, которые ссылаются на эту.", }, image: { empty: "Нет изображения", diff --git a/apps/studio/src/i18n/lazy/fields.tr.ts b/apps/studio/src/i18n/lazy/fields.tr.ts index 5461d1ea..f51498bc 100644 --- a/apps/studio/src/i18n/lazy/fields.tr.ts +++ b/apps/studio/src/i18n/lazy/fields.tr.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): tr `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 62db91a3f4d8c37a +// hash: bae0f16023e18762 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -27,6 +27,10 @@ export const messages: NamespaceMessages<"fields"> = { noValue: "Değer yok", clear: "Temizle", originalImage: "Orijinalin görseli", + sharedList: "Çevrilmez: bu liste tüm dillerde ortak.", + originalItems: "{count, plural, one {# öğe} other {# öğe}}", + itemLegend: + "{named, select, yes {Öğe {index, number}: {name}} no {Öğe {index, number}} other {Öğe {index, number}}}", date: { date: "Tarih", pickDate: "Tarih seç", @@ -52,6 +56,75 @@ export const messages: NamespaceMessages<"fields"> = { field_not_localized: "Bu alan tüm dillerde ortak. Orijinalde değiştir.", required_field: "Yayın için gerekli. Bir değer gir ya da kaydı taslak olarak bırak.", too_large: "Bu kaydın alanları birlikte çok büyük.", + unknown_entry: + "Bu kayıt burada seçilemez: silinmiş, arşivlenmiş ya da başka bir koleksiyonda. Başka bir kayıt seç.", + too_many_references: "Bir kayıt, tüm alanlarında toplam en fazla 100 referans içerebilir.", + }, + repeater: { + add: "Öğe ekle", + count: "{count, number}/{max, number}", + empty: "Henüz öğe yok", + moveUp: "{index, number}. öğeyi yukarı taşı", + moveDown: "{index, number}. öğeyi aşağı taşı", + actions: "{index, number}. öğe için işlemler", + menuMoveUp: "Yukarı taşı", + menuMoveDown: "Aşağı taşı", + duplicate: "Kopyasını oluştur", + remove: "Kaldır", + moved: "{from, number}. öğe {to, number}. sıraya taşındı", + removed: "{index, number}. öğe kaldırıldı", + invalidItems: + "{count, plural, one {# daraltılmış öğe ilgi bekliyor:} other {# daraltılmış öğe ilgi bekliyor:}}", + removedToast: "Kaldırıldı: {item}", + undo: "Geri al", + }, + reference: { + choose: "Kayıt seç", + add: "Kayıt ekle", + change: "Değiştir", + full: "{max, plural, one {# kayıt alır.} other {En fazla # kayıt alır.}}", + meta: "{collection} · {language}", + notFound: "Bulunamadı", + notShown: "Yayınlanana kadar sitede görünmez", + notFoundHint: "Bu kayıt silinmiş. Kaldır ya da başka bir kayıt seç.", + moveUp: "“{title}” kaydını yukarı taşı", + moveDown: "“{title}” kaydını aşağı taşı", + actions: "“{title}” için işlemler", + open: "Aç", + menuMoveUp: "Yukarı taşı", + menuMoveDown: "Aşağı taşı", + remove: "Kaldır", + picker: { + title: "{multiple, select, yes {Kayıtları seç} no {Bir kayıt seç} other {Bir kayıt seç}}", + description: "Kaynak: {collections}.", + search: "Kayıtlarda ara", + results: + "{count, plural, =0 {Kayıt bulunamadı} one {# kayıt bulundu} other {# kayıt bulundu}}", + searching: "Aranıyor…", + noResults: "Kayıt bulunamadı", + failed: "Arama tamamlanmadı. Tekrar dene.", + more: "Daha fazla göster", + keepSearching: "Aramaya devam et", + truncated: "Henüz tüm kayıtlarda arama yapılmadı.", + remaining: + "{count, plural, =0 {Daha fazla seçemezsin} one {# tane daha seçebilirsin} other {# tane daha seçebilirsin}}", + unavailable: "Zaten seçili", + add: "{count, plural, =0 {Ekle} one {# kayıt ekle} other {# kayıt ekle}}", + }, + }, + usedBy: { + title: "Kullanıldığı yerler", + description: "Alanları bu kayda referans veren kayıtlar; önce yayınlananlar.", + empty: "Henüz hiçbir kayıt buna referans vermiyor", + emptyDescription: "Bir referans alanında bunu seçen kayıtlar burada görünür.", + entry: "Kayıt", + status: "Durum", + caption: "Bu kayda referans veren kayıtlar", + detail: "{collection} · {language} · {field}", + subField: "{field}: {sub}", + shownBy: "Şu dillerde de görünür: {languages}", + more: "Daha fazla göster", + failed: "Bu kayda referans veren kayıtlar yüklenemedi.", }, image: { empty: "Görsel yok", diff --git a/apps/studio/src/i18n/lazy/fields.uk.ts b/apps/studio/src/i18n/lazy/fields.uk.ts index b714ebe9..493322f2 100644 --- a/apps/studio/src/i18n/lazy/fields.uk.ts +++ b/apps/studio/src/i18n/lazy/fields.uk.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): uk `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 5813269dd70b9bfb +// hash: 48aff8484cee24f4 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -27,6 +27,11 @@ export const messages: NamespaceMessages<"fields"> = { noValue: "Немає значення", clear: "Очистити", originalImage: "Зображення оригіналу", + sharedList: "Не перекладається: цей список спільний для всіх мов.", + originalItems: + "{count, plural, one {# елемент} few {# елементи} many {# елементів} other {# елемента}}", + itemLegend: + "{named, select, yes {Елемент {index, number}: {name}} no {Елемент {index, number}} other {Елемент {index, number}}}", date: { date: "Дата", pickDate: "Вибрати дату", @@ -53,6 +58,76 @@ export const messages: NamespaceMessages<"fields"> = { field_not_localized: "Це поле спільне для всіх мов. Змініть його в оригіналі.", required_field: "Потрібно для публікації. Додайте значення або залиште запис чернеткою.", too_large: "Поля цього запису разом завеликі.", + unknown_entry: + "Цей запис тут вибрати не можна: його видалено, архівовано або він в іншій колекції. Виберіть інший.", + too_many_references: "Запис може містити щонайбільше 100 посилань у всіх полях разом.", + }, + repeater: { + add: "Додати елемент", + count: "{count, number} з {max, number}", + empty: "Елементів поки немає", + moveUp: "Перемістити елемент {index, number} вгору", + moveDown: "Перемістити елемент {index, number} вниз", + actions: "Дії для елемента {index, number}", + menuMoveUp: "Перемістити вгору", + menuMoveDown: "Перемістити вниз", + duplicate: "Дублювати", + remove: "Видалити", + moved: "Елемент {from, number} переміщено на позицію {to, number}", + removed: "Елемент {index, number} видалено", + invalidItems: + "{count, plural, one {# згорнутий елемент потребує уваги:} few {# згорнуті елементи потребують уваги:} many {# згорнутих елементів потребують уваги:} other {# згорнутого елемента потребують уваги:}}", + removedToast: "Видалено: {item}", + undo: "Скасувати", + }, + reference: { + choose: "Вибрати запис", + add: "Додати записи", + change: "Замінити", + full: "{max, plural, one {Максимум # запис.} few {Максимум # записи.} many {Максимум # записів.} other {Максимум # запису.}}", + meta: "{collection} · {language}", + notFound: "Не знайдено", + notShown: "Не з’явиться на сайті до публікації", + notFoundHint: "Цей запис видалено. Приберіть його або виберіть інший.", + moveUp: "Перемістити «{title}» вгору", + moveDown: "Перемістити «{title}» вниз", + actions: "Дії для «{title}»", + open: "Відкрити", + menuMoveUp: "Перемістити вгору", + menuMoveDown: "Перемістити вниз", + remove: "Прибрати", + picker: { + title: + "{multiple, select, yes {Виберіть записи} no {Виберіть запис} other {Виберіть запис}}", + description: "Джерело: {collections}.", + search: "Пошук записів", + results: + "{count, plural, =0 {Записів не знайдено} one {Знайдено # запис} few {Знайдено # записи} many {Знайдено # записів} other {Знайдено # запису}}", + searching: "Пошук…", + noResults: "Записів не знайдено", + failed: "Пошук не завершився. Спробуйте ще.", + more: "Показати ще", + keepSearching: "Шукати далі", + truncated: "Пошук ще охопив не всі записи.", + remaining: + "{count, plural, =0 {Більше вибрати не можна} one {Можна вибрати ще #} few {Можна вибрати ще #} many {Можна вибрати ще #} other {Можна вибрати ще #}}", + unavailable: "Уже вибрано", + add: "{count, plural, =0 {Додати} one {Додати # запис} few {Додати # записи} many {Додати # записів} other {Додати # запису}}", + }, + }, + usedBy: { + title: "Де використовується", + description: "Записи, поля яких посилаються на цей; спочатку опубліковані.", + empty: "Поки жоден запис не посилається на цей", + emptyDescription: "Тут з’являться записи, які виберуть його в полі посилання.", + entry: "Запис", + status: "Статус", + caption: "Записи, які посилаються на цей", + detail: "{collection} · {language} · {field}", + subField: "{field}: {sub}", + shownBy: "Також показано мовами: {languages}", + more: "Показати ще", + failed: "Не вдалося завантажити записи, які посилаються на цей.", }, image: { empty: "Немає зображення", diff --git a/apps/studio/src/i18n/lazy/fields.zh-Hans.ts b/apps/studio/src/i18n/lazy/fields.zh-Hans.ts index 7231b8ef..bd71af76 100644 --- a/apps/studio/src/i18n/lazy/fields.zh-Hans.ts +++ b/apps/studio/src/i18n/lazy/fields.zh-Hans.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): zh-Hans `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: f35635e34d8c5d2b +// hash: 821b73dcabd1f0ba import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -27,6 +27,10 @@ export const messages: NamespaceMessages<"fields"> = { noValue: "无值", clear: "清除", originalImage: "原文的图片", + sharedList: "无需翻译:此列表在所有语言间共享。", + originalItems: "{count, plural, other {# 个项目}}", + itemLegend: + "{named, select, yes {第 {index, number} 项:{name}} no {第 {index, number} 项} other {第 {index, number} 项}}", date: { date: "日期", pickDate: "选择日期", @@ -52,6 +56,71 @@ export const messages: NamespaceMessages<"fields"> = { field_not_localized: "此字段在所有语言间共享。请在原文中修改。", required_field: "发布所需。请填写一个值,或将条目保留为草稿。", too_large: "此条目的字段合计过大。", + unknown_entry: "此处无法选择该条目:它已被删除、已归档或属于其他集合。请另选一个。", + too_many_references: "一个条目的所有字段合计最多可包含 100 个引用。", + }, + repeater: { + add: "添加项目", + count: "{count, number}/{max, number}", + empty: "暂无项目", + moveUp: "将第 {index, number} 项上移", + moveDown: "将第 {index, number} 项下移", + actions: "第 {index, number} 项的操作", + menuMoveUp: "上移", + menuMoveDown: "下移", + duplicate: "复制", + remove: "移除", + moved: "第 {from, number} 项已移至第 {to, number} 位", + removed: "已移除第 {index, number} 项", + invalidItems: "{count, plural, other {# 个已折叠的项目需要处理:}}", + removedToast: "已移除:{item}", + undo: "撤销", + }, + reference: { + choose: "选择条目", + add: "添加条目", + change: "更换", + full: "{max, plural, other {最多可容纳 # 个条目。}}", + meta: "{collection} · {language}", + notFound: "未找到", + notShown: "发布前不会在网站上显示", + notFoundHint: "此条目已被删除。请移除它或另选一个。", + moveUp: "将“{title}”上移", + moveDown: "将“{title}”下移", + actions: "“{title}”的操作", + open: "打开", + menuMoveUp: "上移", + menuMoveDown: "下移", + remove: "移除", + picker: { + title: "{multiple, select, yes {选择条目} no {选择一个条目} other {选择一个条目}}", + description: "来自{collections}。", + search: "搜索条目", + results: "{count, plural, =0 {未找到条目} other {找到 # 个条目}}", + searching: "正在搜索…", + noResults: "未找到条目", + failed: "搜索未完成。请重试。", + more: "显示更多", + keepSearching: "继续搜索", + truncated: "尚未搜索全部条目。", + remaining: "{count, plural, =0 {无法再选择} other {还可以选择 # 个}}", + unavailable: "已选择", + add: "{count, plural, =0 {添加} other {添加 # 个条目}}", + }, + }, + usedBy: { + title: "引用方", + description: "字段引用了此条目的条目,已发布的排在前面。", + empty: "暂无条目引用此条目", + emptyDescription: "在引用字段中选择了它的条目会显示在这里。", + entry: "条目", + status: "状态", + caption: "引用此条目的条目", + detail: "{collection} · {language} · {field}", + subField: "{field}:{sub}", + shownBy: "也显示于:{languages}", + more: "显示更多", + failed: "无法加载引用此条目的条目。", }, image: { empty: "无图片", diff --git a/apps/studio/src/lib/api/references.ts b/apps/studio/src/lib/api/references.ts new file mode 100644 index 00000000..eda3a882 --- /dev/null +++ b/apps/studio/src/lib/api/references.ts @@ -0,0 +1,70 @@ +/** + * RFC-0045 §5 (collections stage 4): the entry picker, the chosen targets' members and an entry's + * referrers. Imported directly by the lazy field components, not through the `api` barrel, so the + * shell never carries it (`LAZY_ONLY`). + */ +import type { PublishStatus } from "@cloudhal/core-contracts"; +import { query, request } from "./http.ts"; + +/** An entry as the picker and the reference reads name it (`referenceCard` in core-ts). */ +export interface ReferenceCard { + /** Its translation group id: what a reference stores. */ + groupId: string; + id: string; + title: string; + collection: string; + status: PublishStatus; + locale: string; +} + +/** `GET …/entries/pick`: at most 20 cards in key order, never archived ones. */ +export interface PickPage { + entries: ReferenceCard[]; + /** Where to continue; absent once every allowed collection was read. */ + cursor?: string; + /** The server's row budget ended before `limit` matches: ask again with `cursor`. */ + truncated?: true; +} + +/** One entry that holds a reference to the group (`listReferrers` in core-ts). */ +export interface Referrer extends ReferenceCard { + /** The field path holding the value: `venue`, `speakers.person`. */ + field: string; + /** Same-site members of the holder's group that show its value without holding it. */ + shownBy: ReferenceCard[]; +} + +export interface ReferrersPage { + groupId: string; + referrers: Referrer[]; + /** The next page's offset. */ + next?: number; +} + +export const referencesApi = { + pick: ( + siteId: string, + q: { collections: readonly string[]; q: string; locale?: string; cursor?: string }, + ) => + request( + `/api/sites/${siteId}/entries/pick${query({ + collections: q.collections.join(","), + q: q.q, + locale: q.locale, + cursor: q.cursor, + })}`, + ), + /** Each group's members in the site, any status; `[]` for a group with none left. */ + groups: (siteId: string, ids: readonly string[]) => + request<{ groups: Record }>( + `/api/sites/${siteId}/entries/groups${query({ ids: ids.join(",") })}`, + ), + /** The referrers of the entry's group, published holders first. */ + referrers: (siteId: string, entryId: string, page: { limit?: number; offset?: number } = {}) => + request( + `/api/sites/${siteId}/entries/${entryId}/references${query({ + limit: page.limit, + offset: page.offset, + })}`, + ), +}; diff --git a/apps/studio/src/lib/field-references.ts b/apps/studio/src/lib/field-references.ts new file mode 100644 index 00000000..8a43018c --- /dev/null +++ b/apps/studio/src/lib/field-references.ts @@ -0,0 +1,84 @@ +/** + * RFC-0045 in Studio: pure helpers for reference and repeater fields in the entry editor — the + * group ids an entry's values name, the member a target shows, the API's issues by path, and the + * words of a referrer's field ("Used by"). + * + * Loads with the fields UI only (`bundle-budget.ts` `LAZY_ONLY`). + */ +import type { FieldDefinition, Site } from "@cloudhal/core-contracts"; +import { fieldLabel } from "@cloudhal/core-ts/fields"; +import type { ReferenceCard } from "./api/references.ts"; +import type { FieldIssue } from "./fields.ts"; + +const ids = (v: unknown): string[] => + typeof v === "string" ? [v] : Array.isArray(v) ? v.filter((x) => typeof x === "string") : []; + +const isItem = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v); + +/** Every group id the values name, in references and in repeater items' reference sub-fields. */ +export function referenceIdsIn( + definitions: readonly FieldDefinition[], + values: Readonly> | undefined, +): string[] { + const out = new Set(); + for (const def of definitions) { + const v = values?.[def.key]; + if (def.type === "reference") for (const id of ids(v)) out.add(id); + if (def.type !== "repeater" || !Array.isArray(v)) continue; + const subs = def.fields.filter((s) => s.type === "reference").map((s) => s.key); + for (const item of v.filter(isItem)) + for (const key of subs) for (const id of ids(item[key])) out.add(id); + } + return [...out]; +} + +/** + * The member a target stands for in the editor: in an allowed collection, the one in the entry's + * language, else the site's default language's, else the first. Any status: the list says when it + * will not show. + */ +export function chooseMember( + members: readonly ReferenceCard[], + options: { locale: string; defaultLocale: string; collections: readonly string[] }, +): ReferenceCard | undefined { + const allowed = members.filter((m) => options.collections.includes(m.collection)); + return ( + allowed.find((m) => m.locale === options.locale) ?? + allowed.find((m) => m.locale === options.defaultLocale) ?? + allowed[0] + ); +} + +/** + * The first issue at each path under `fields`, relative to it: `fields/startsAt/at` → + * `startsAt/at`, `fields/speakers/2/person` → `speakers/2/person`. + */ +export function issuesByPath(issues: readonly FieldIssue[]): Record { + const out: Record = {}; + for (const issue of issues) { + if (!issue.path.startsWith("fields/")) continue; + const path = issue.path.slice("fields/".length); + if (path && !out[path]) out[path] = issue; + } + return out; +} + +/** + * The words of a referrer's field path (`venue`, `speakers.person`) in its collection, in the + * Studio language: the field's label, and the sub-field's in a repeater. Keys when undefined. + */ +export function referrerFieldWords( + site: Site | undefined, + collection: string, + path: string, + locale: string, +): { field: string; sub?: string } { + const [key = path, subKey] = path.split("."); + const defaultLocale = site?.defaultLocale ?? locale; + const def = site?.collections?.[collection]?.fields?.find((d) => d.key === key); + const field = def ? fieldLabel(def, locale, defaultLocale) : key; + if (subKey === undefined) return { field }; + const sub = def?.type === "repeater" ? def.fields.find((s) => s.key === subKey) : undefined; + return { field, sub: sub ? fieldLabel(sub, locale, defaultLocale) : subKey }; +} diff --git a/apps/studio/src/routes/ArticleEditorPage.tsx b/apps/studio/src/routes/ArticleEditorPage.tsx index 529352c1..d635143e 100644 --- a/apps/studio/src/routes/ArticleEditorPage.tsx +++ b/apps/studio/src/routes/ArticleEditorPage.tsx @@ -21,7 +21,7 @@ import { import { Copy, Trash } from "@phosphor-icons/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "@tanstack/react-router"; -import { lazy, Suspense, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { ArticleCorrectionsCard } from "../components/ArticleCorrections.tsx"; import { ArticleTermsCard } from "../components/ArticleTerms.tsx"; import { ArticleReachCards } from "../components/article-editor/ArticleAside.tsx"; @@ -44,6 +44,7 @@ import { ScheduleAside, ScheduleLock, } from "../components/EditorSchedule.tsx"; +import { EntryFieldCards } from "../components/EntryFieldCards.tsx"; import { EntryPath } from "../components/EntryPath.tsx"; import { EntryStatus } from "../components/EntryStatus.tsx"; import { ErrorBanner } from "../components/ErrorBanner.tsx"; @@ -69,11 +70,6 @@ import { missingPrimaries } from "../lib/taxonomy.ts"; import { isDirty, setOptional } from "../lib/util.ts"; import { slugify } from "../lib/workflow.ts"; -// RFC-0041 §7: the typed fields form loads only for collections that define fields. -const EntryFieldsCard = lazy(() => - import("../components/fields/EntryFieldsCard.tsx").then((m) => ({ default: m.EntryFieldsCard })), -); - /** A copy of the article as a new draft with a free slug. */ function duplicateOf(entry: ContentEntry, title: string): NewEntry { const suffix = `-copy-${Date.now().toString(36).slice(-4)}`; @@ -447,18 +443,14 @@ export function ArticleEditorPage() { className="w-full" /> - {site.data?.collections?.[draft.collection]?.fields && ( - - setDraft({ ...draft, fields })} - error={save.error} - disabled={readOnlyReason !== undefined || locked} - /> - - )} + setDraft({ ...draft, fields })} + error={save.error} + disabled={readOnlyReason !== undefined || locked} + /> { diff --git a/apps/studio/test/fields-references.test.tsx b/apps/studio/test/fields-references.test.tsx new file mode 100644 index 00000000..e54ce220 --- /dev/null +++ b/apps/studio/test/fields-references.test.tsx @@ -0,0 +1,259 @@ +/** + * RFC-0045 §5 (rollout step 6a): reference and repeater fields in the entry editor — the chosen + * targets through `GET …/entries/groups`, the picker over `GET …/entries/pick`, repeater items + * with an undo toast, item errors by path, and the "Used by" card. + */ +import type { ContentEntry, FieldDefinition, Site } from "@cloudhal/core-contracts"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EntryFieldCards } from "../src/components/EntryFieldCards.tsx"; +import { EntryFieldsCard } from "../src/components/fields/EntryFieldsCard.tsx"; +import { ApiRequestError } from "../src/lib/api/http.ts"; +import { on, serve, wrap } from "./roles-fixtures.tsx"; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +const L = (label: string) => ({ en: { label } }); +const DEFS: FieldDefinition[] = [ + { + key: "venue", + type: "reference", + labels: L("Venue"), + localized: false, + collections: ["places"], + }, + { + key: "speakers", + type: "repeater", + labels: L("Speakers"), + localized: false, + maxItems: 5, + fields: [ + { key: "name", type: "text", labels: L("Name") }, + { key: "role", type: "text", labels: L("Role"), requiredToPublish: true }, + ], + }, +]; + +const SITE = { + schemaVersion: 1, + id: "site_1", + projectId: "proj_1", + name: "Site", + slug: "site", + defaultLocale: "en", + locales: ["en", "tr"], + domains: [], + pageIds: [], + theme: { name: "blog" }, + collections: { + events: { fields: DEFS }, + places: { labels: { en: { singular: "Place", plural: "Places" } } }, + }, + rev: 1, + createdAt: "2026-11-01T10:00:00Z", + updatedAt: "2026-11-01T10:00:00Z", +} as unknown as Site; + +const ENTRY = { + schemaVersion: 1, + id: "entry_event001", + siteId: "site_1", + collection: "events", + slug: "launch", + locale: "en", + status: "draft", + title: "Launch", + body: { format: "markdown", value: "" }, + fields: { venue: "entry_hall0001", speakers: [{ name: "Ada", role: "Host" }] }, + tags: [], + author: { kind: "human", id: "u_owner" }, + rev: 3, + createdAt: "2026-11-01T10:00:00Z", + updatedAt: "2026-11-01T10:00:00Z", +} as unknown as ContentEntry; + +const card = (groupId: string, title: string, status = "published", locale = "en") => ({ + groupId, + id: groupId, + title, + collection: "places", + status, + locale, +}); + +function Harness({ + entry = ENTRY, + error, + onFields = () => {}, +}: { + entry?: ContentEntry; + error?: unknown; + onFields?: (f: Record) => void; +}) { + const [draft, setDraft] = useState(entry); + return ( + { + setDraft({ ...draft, fields }); + onFields(fields); + }} + error={error} + disabled={false} + /> + ); +} + +const groups = (body: Record) => + on("GET", "/api/sites/site_1/entries/groups", { status: 200, body: { groups: body } }); + +describe("Entry editor › reference fields", () => { + it("shows the chosen target with its state, and replaces it through the picker", async () => { + const fetch = serve( + groups({ entry_hall0001: [card("entry_hall0001", "Main hall", "draft")] }), + on("GET", "/api/sites/site_1/entries/pick", { + status: 200, + body: { entries: [card("entry_room0001", "Room B")] }, + }), + ); + const onFields = vi.fn(); + render(wrap()); + const venue = await screen.findByRole("group", { name: "Venue" }); + expect(await within(venue).findByRole("link", { name: "Main hall" })).toHaveAttribute( + "href", + "/sites/site_1/collections/places/entry_hall0001", + ); + expect(within(venue).getByText("Draft")).toBeInTheDocument(); + expect(within(venue).getByText("Will not show until published")).toBeInTheDocument(); + expect(within(venue).getByText("Places · English")).toBeInTheDocument(); + + fireEvent.click(within(venue).getByRole("button", { name: "Change" })); + const search = await screen.findByRole("combobox", { name: "Search entries" }); + const room = await screen.findByRole("option", { name: /Room B/ }); + expect(document.querySelector("[data-entry-picker-status]")).toHaveTextContent("1 entry found"); + const pick = fetch.mock.calls.find(([u]) => String(u).includes("/entries/pick")); + expect(String(pick?.[0])).toContain("collections=places"); + fireEvent.keyDown(search, { key: "Enter" }); + expect(room).toBeInTheDocument(); + expect(onFields).toHaveBeenLastCalledWith({ ...ENTRY.fields, venue: "entry_room0001" }); + // The picker's card shows at once, before the groups' query answers. + expect(await within(venue).findByText("Room B")).toBeInTheDocument(); + }); + + it("says Not found for a target with no entry left", async () => { + serve(groups({ entry_hall0001: [] })); + render(wrap()); + const venue = await screen.findByRole("group", { name: "Venue" }); + expect(await within(venue).findByText("Not found")).toBeInTheDocument(); + expect(within(venue).getByText("entry_hall0001")).toBeInTheDocument(); + }); +}); + +describe("Entry editor › repeater fields", () => { + it("removes an item and puts it back from the undo toast", async () => { + serve(groups({ entry_hall0001: [card("entry_hall0001", "Main hall")] })); + const onFields = vi.fn(); + render(wrap()); + const item = await screen.findByRole("group", { name: "Item 1: Ada" }); + expect(within(item).getByRole("textbox", { name: "Name" })).toHaveValue("Ada"); + fireEvent.click(within(item).getByRole("button", { name: "Actions for item 1" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "Remove" })); + expect(onFields).toHaveBeenLastCalledWith({ venue: "entry_hall0001" }); + expect(await screen.findByText("Item 1: Ada removed")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Undo" })); + await waitFor(() => + expect(onFields).toHaveBeenLastCalledWith({ + venue: "entry_hall0001", + speakers: [{ name: "Ada", role: "Host" }], + }), + ); + expect(screen.getByRole("group", { name: "Item 1: Ada" })).toBeInTheDocument(); + }); + + it("puts the API's item issues on their control, in the Studio language", async () => { + serve(groups({ entry_hall0001: [card("entry_hall0001", "Main hall")] })); + const error = new ApiRequestError(422, { + error: "required_field", + message: "needed", + details: { + issues: [{ path: "fields/speakers/0/role", code: "required_field", message: "needed" }], + }, + }); + render(wrap()); + const item = await screen.findByRole("group", { name: "Item 1: Ada" }); + expect( + within(item).getByText("Needed to publish. Add a value, or keep the entry as a draft."), + ).toBeInTheDocument(); + }); +}); + +describe("Entry editor › Used by", () => { + it("lists referrers where some field can reference the collection", async () => { + serve( + on("GET", "/api/sites/site_1/entries/entry_hall0001/references", { + status: 200, + body: { + groupId: "entry_hall0001", + referrers: [ + { + ...card("entry_event001", "Launch"), + collection: "events", + field: "venue", + shownBy: [{ ...card("entry_event0tr", "Lansman", "published", "tr") }], + }, + ], + }, + }), + ); + const hall = { + ...ENTRY, + id: "entry_hall0001", + collection: "places", + title: "Main hall", + fields: {}, + } as ContentEntry; + render( + wrap( + {}} + error={undefined} + disabled={false} + />, + ), + ); + const table = await screen.findByRole("table", { name: "Entries that reference this one" }); + expect(await within(table).findByRole("link", { name: "Launch" })).toHaveAttribute( + "href", + "/sites/site_1/collections/events/entry_event001", + ); + expect(within(table).getByText(/· English · Venue$/)).toBeInTheDocument(); + expect(within(table).getByText("Also shown in Turkish")).toBeInTheDocument(); + }); + + it("shows nothing for a collection no field can reference", () => { + serve(); + const { container } = render( + wrap( + {}} + error={undefined} + disabled={false} + />, + ), + ); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/apps/studio/test/i18n-messages.test.ts b/apps/studio/test/i18n-messages.test.ts index 64639239..dddafe12 100644 --- a/apps/studio/test/i18n-messages.test.ts +++ b/apps/studio/test/i18n-messages.test.ts @@ -75,7 +75,9 @@ describe("message catalogs (RFC-0032 §5)", () => { } }); expect(errors).toEqual([]); - }); + // Compiles every catalog of every language synchronously: about 5 s alone, CPU-bound, so it + // passed Studio's 20 s default only when `pnpm check` left it a core (RFC-0045 step 6a). + }, 60_000); it("arrives compiled: a message with syntax is data, plain ones stay strings", () => { const english = messages("en"); diff --git a/docs/design/DESIGN.md b/docs/design/DESIGN.md index 966dad90..1b91b52a 100644 --- a/docs/design/DESIGN.md +++ b/docs/design/DESIGN.md @@ -479,6 +479,7 @@ Site › Settings gets an **Appearance** section after Domains (`components/Appe ### 2ah. Typed custom fields (RFC-0041 §7, ADR-0043) Words in the lazy `fields` namespace (loaded with the entry editor and collection settings routes). The UI (`components/fields/`, `lib/fields.ts`, `lib/field-form.ts`, the `EntryFieldsForm` block and `zoned-time.ts`, `@cloudhal/core-ts/fields`) is `LAZY_ONLY` in `bundle-budget.ts` and loads only for a collection that has definitions. - **Entry editor › Fields** (`EntryFieldsCard`, a `SectionCard` after "Article", only when the collection defines fields): the `EntryFieldsForm` block, saved with the entry (Save and the status actions send `fields`). One control per type: text → `Input` (`InputArea` when `multiline`; `maxLength` on the control), rich text → the body editor (`BodyEditor size="sm"`, lazy Tiptap) under a `Label`, number → `Input type=number` (min/max/step from the definition), boolean → `Switch`, date → `DateTimeField dateOnly`, datetime → `DateTimeField` in the value's own zone (the site's for a new value; stored as `{ at, timeZone }`), URL → `Input type=url`, image → `FieldImage` (thumbnail, title and size in an `InsetPanel row`, "Choose image" / "Change" / "Remove", the editor's `AssetPickerDialog`), select → `Select` with a "No value" choice, or a `Checkbox.Group` when `multiple`. Dates get a ghost "Clear". Labels and help come from the definition in the Studio language (`fieldLabel` / `fieldHelp` / `optionLabel`, then the site's default language); the description starts with an outline `Badge` "Needed to publish" for `requiredToPublish`, then help and the limits in words ("Up to 80 characters.", "From 1 to 10."). The last save's `details.issues` show on their field (`fields//…` → that field), worded per code (`fields.issues.*`); the save error itself stays in the page's `ErrorBanner`. **Translations** (the group's original in the same site): non-localized fields show their control disabled with the original's value and "From the original (English) · Open the original" (a `Link` to its editor); empty localized fields show the original's value as their placeholder, or "The original: …" under controls without one (choices, dates, yes/no, images). +- **Entry editor › references and repeaters** (RFC-0045 §5; words in `fields.reference.*`, `fields.repeater.*`, `fields.usedBy.*`): a reference is `FieldReference` — `ReferenceList` of the stored targets (each group's member in the entry's language, else the default language's, else the first, read from `GET …/entries/groups`; any status, so drafts and archived ones say they will not show and deleted ones say "Not found") and an `EntryPicker` over `GET …/entries/pick` (the allowed collections, keyset cursor; the entry's own group and the chosen ones can't be picked); the trigger is secondary "Choose entry", "Change" for a chosen single target, "Add entries" with "Holds up to n entries." for several. A repeater is `RepeaterField` with each sub-field's `FieldControl` (images through `FieldImage`, references through `FieldReference`); its legend is `fields.editor.itemLegend` ("Item 2: Ada Lovelace", from `itemLabel`), "Remove" shows an info toast "Item 2: … removed" with "Undo" for 10 s, and a list that is not localized says "Not translated: this list is shared by every language." on multilingual sites. Save issues map by path (`fields/speakers/2/person` → that control). **Used by** (`UsedByCard`, a flush `SectionCard` after Fields, only when some field of the site can reference the collection): `UsedByList` from `GET …/entries/:id/references`, published referrers first, "Show more" while pages remain, `SectionEmpty` (`LinkSimple`) when none. Both cards load through `EntryFieldCards` as their own lazy chunks. - **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). @@ -613,7 +614,8 @@ Studio must use each component where it fits; "later" means the surface does not | CalendarMonth / AgendaList / CalendarNav (Cloudhal blocks) | the schedules calendar: the APG month grid (one tab stop, chips with "+N more", drag and Alt+arrow moves through `onMove`, dots and a day list on phones), the agenda grouped by day under sticky headings with a "Now" rule and folds, and the ‹ Today › / period / Agenda–Month row (§2x) | | ScheduleItem / ScheduleStatusBadge / ScheduleFilters / TimezoneNote (Cloudhal blocks) | one scheduled thing as a chip or row in the schedules visual language (proposals dashed, D14), every release/automation/run/approval state as an icon + word badge, the calendar's multi-value filters and views, and the site-zone note with "Show in my time" (§2x) | | DateTimeField / RecurrenceEditor / NextRuns / RescheduleDialog / ScheduleRunRow (Cloudhal blocks) | date + 24-hour time in the site zone (the lazy `DatePicker` and a typed time), recurrence presets and the RRULE subset, the server's next runs with clock-change badges, "Move to…" with the approval kept or voided, and the run history row with lateness, budget use and the taint marker (§2x) | -| EntryFieldsForm (Cloudhal block) | an entry's typed custom fields (RFC-0041): one Kumo control per field type from the collection's definitions — `Input`/`InputArea`, `Switch`, `DateTimeField` (date only, or date and time in the value's zone), `Select`/`Checkbox.Group` — labels and help from the definitions in the Studio language, the "Needed to publish" badge, errors per field, and translation fallbacks (shared fields read-only with a link to the original, the original's value as placeholder); rich text and images come from the host (`renderRichText`, `renderImage`). Every word is a prop (§2ah) | +| EntryFieldsForm (Cloudhal block) | an entry's typed custom fields (RFC-0041): one Kumo control per field type from the collection's definitions — `Input`/`InputArea`, `Switch`, `DateTimeField` (date only, or date and time in the value's zone), `Select`/`Checkbox.Group` — labels and help from the definitions in the Studio language, the "Needed to publish" badge, errors per field, and translation fallbacks (shared fields read-only with a link to the original, the original's value as placeholder); rich text, images and references come from the host (`renderRichText`, `renderImage`, `renderReference`); repeaters render as `RepeaterField`; errors map by path (`speakers/2/person`). Every word is a prop (§2ah) | +| FieldControl / RepeaterField / ReferenceList / EntryPicker / UsedByList (Cloudhal blocks) | RFC-0045 §5: `FieldControl` is one typed control (`EntryFieldsForm` and repeater items share it; a reference is a `fieldset` around the host's picker). `RepeaterField`: items as `fieldset`s named by an ICU legend, folded in `Collapsible`s, "Add item" with "3 of 20", up/down icon buttons from `sm` and a ⋯ `DropdownMenu` (move, duplicate, remove), focus to the new item's first control after add and to the next item's toggle (or "Add item") after remove, an undo through `onRemoved`, moves announced politely, items with problems opened on a new error map and the collapsed ones listed above. `ReferenceList`: `InsetPanel` rows (title linked, collection · language, "Draft"/"In review"/"Archived" with "Will not show until published", "Not found" with the id in mono), up/down from `sm` and ⋯ (open, move, remove). `EntryPicker`: a `LayerDialog` opened by its `trigger` (focus returns to it), a labelled search over a combobox listbox (`aria-activedescendant`, arrows, Home, End, Enter), a polite result count, collection and status `Badge`s, one pick closes, several are ticked up to the remaining count, "Show more" / "Keep searching" follows the cursor; it makes no API calls (`search`). `UsedByList`: a two-column `Table` (entry with where the value sits and who also shows it, status) for "Used by" and the delete dialog. Every word is a prop | | ToggletipBadge (Cloudhal block) | a badge that explains itself: a Kumo `Badge` (variant, 14 px icon) inside a button that opens its `reason` as a `Tooltip` on hover, keyboard focus and a press (a press keeps it open until a second press, Escape or a press elsewhere), wrapped at 18 rem (`max-w-72`), the reason repeated for screen readers in the accessible name; every word a prop — "Ended (collection rule)" in the collection list and entry editor (§2ah) | | Switch | instant settings; browser notifications (Account › Notifications); page "Hide from search engines" (§2o) | | Table | all lists | diff --git a/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md b/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md index 3969fa2c..a490fd42 100644 --- a/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md +++ b/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md @@ -1389,3 +1389,13 @@ Round 1 dropped the "fits" shortcut for text ↔ reference, but then the table r - **Removals during `rename_field`.** While a top-level rename runs, an entry not rewritten yet holds the value under the old key, and the writer moves it. The two keys are one field: removing either (`clearFields`, or `null` in `fields`) removes both, so the move cannot bring the value back, the old key is a known name meanwhile, and setting one while removing the other is `invalid`. This is MCP's patch reading; the API and Studio write whole entries. - **A continuation's `onInvalid: "clear"`** applies to its report too (`continueFieldOperation`): values it drops are counted as cleared, with no `field_values_unconvertible` refusal. MCP's continuation headline is the stored operation's. - **`list_entries`** returns `groupId`; `where` on a reference field stays refused (`invalid_query`) until step 8. + +### Amended in 6a (2026-11-13, rollout step 6, first PR: blocks and the entry editor) + +- **A route for the chosen targets** (§5 "API"): `GET /sites/:siteId/entries/groups?ids=a,b` (`content:read`) answers each group's members in the site, any status, as the picker's cards; `[]` for a group with none left. The picker route finds new targets only, and `references` lists referrers, so the editor had no way to name a stored id. At most 100 ids, the per-entry cap. +- **The member the editor shows** for a target: in an allowed collection, the one in the entry's language, else the default language's, else the first, whatever its status. "Draft", "In review" and "Archived" say "Will not show until published"; a group with no member is "Not found". This is the editor's view, not `pickMember`: whether a published member in another language shows depends on `localization.fallback`, which the publish warnings (step 6b, `referenceIssues`) report. +- **Legends** read "Item 2: Ada Lovelace" (`fields.editor.itemLegend`, a `select` on whether the item has a name). The contract has no singular label for a repeater, so "Speaker 2" is not available; the item's name comes from `itemLabel`, else the first text, select or reference sub-field. +- **Problems in collapsed items** are listed above that repeater's items (a button per item opens it and focuses its first control), and every item a new save error names opens. +- **`EntryPicker`** marks several-choice options with a checkbox icon inside each `option` (the search field keeps the focus, `aria-activedescendant`), not a Kumo `Checkbox`, so the listbox has no nested controls. One choice picks and closes. A group found in several languages is listed once. +- **`FieldControl`** frames a reference in a `fieldset` (label, description, error); the host's `renderReference` fills it. +- **Gate:** step 6a adds no capability check. No production collection can define the two types before step 7, so the editor never meets them there; the builder (6b) offers them only behind the server's capability. diff --git a/packages/api/src/playground.ts b/packages/api/src/playground.ts index a10d84f7..d501910c 100644 --- a/packages/api/src/playground.ts +++ b/packages/api/src/playground.ts @@ -144,8 +144,9 @@ export const PLAYGROUND_ALLOW: ReadonlySet = new Set([ `POST ${S}/entries/:entryId/preview`, `GET ${S}/entries/:entryId/structured-data`, `POST ${S}/entries/preview-fragment`, - // RFC-0045 §5: the entry picker and an entry's referrers, read-only (content:read). + // RFC-0045 §5: the entry picker, chosen targets and an entry's referrers, read-only (content:read). `GET ${S}/entries/pick`, + `GET ${S}/entries/groups`, `GET ${S}/entries/:entryId/references`, `GET ${S}/entries/:entryId`, `PUT ${S}/entries/:entryId`, diff --git a/packages/api/src/routes/entry-references.ts b/packages/api/src/routes/entry-references.ts index fbd0f0ba..cc06362f 100644 --- a/packages/api/src/routes/entry-references.ts +++ b/packages/api/src/routes/entry-references.ts @@ -7,13 +7,17 @@ * `cursor` and `truncated` when the row budget ended first (`entry-pick.ts`). `q` is at most * 100 characters and refused before it is read further; a per-member window of the * `rate_limits` counters applies. + * - `GET …/entries/groups?ids=a,b` (rollout step 6, Studio's chosen targets): each group's members + * in this site, any status, as `{groupId, id, title, collection, status, locale}` cards (the + * membership rule, `getGroupMembers`); a group with no member left gets an empty list. At most + * 100 ids, the per-entry reference cap. * - `GET …/entries/:entryId/references?limit=&offset=`: the referrers of the entry's translation * group from `entry_refs`, published holders first, each holder with the same-site members that * show its value (`shared` 1, or 2 without their own value), paged (`listReferrers`, which MCP's * `list_references` shares). */ -import { listReferrers } from "@cloudhal/core-ts"; -import { memberGroup } from "@cloudhal/core-ts/fields"; +import { getGroupMembers, listReferrers, referenceCard } from "@cloudhal/core-ts"; +import { FIELD_LIMITS, GROUP_ID_PATTERN, memberGroup } from "@cloudhal/core-ts/fields"; import type { Context } from "hono"; import { Hono } from "hono"; import type { Env } from "../context.ts"; @@ -31,6 +35,16 @@ const PICK_LIMIT = 20; /** Picker searches per member and site per minute. */ const PICK_WINDOW = { hits: 120, ms: 60_000 }; const REFERRERS_LIMIT = 100; +const GROUP_ID = new RegExp(GROUP_ID_PATTERN); + +/** The groups' query: one to 100 well-formed group ids. */ +function groupIds(c: Context): string[] { + const ids = [...new Set((c.req.query("ids") ?? "").split(",").filter((s) => s !== ""))]; + if (ids.length === 0 || ids.length > FIELD_LIMITS.referencesPerEntry) + throw badRequest(`ids lists one to ${FIELD_LIMITS.referencesPerEntry} translation group ids`); + if (!ids.every((id) => GROUP_ID.test(id))) throw badRequest("ids are translation group ids"); + return ids; +} function intParam(raw: string | undefined, max: number, fallback: number): number { if (raw === undefined || raw === "") return fallback; @@ -74,6 +88,16 @@ export const entryReferences: Hono = new Hono() throw apiError(429, "rate_limited", "too many picker searches; wait a minute"); return c.json(await pickEntries(c.get("store").sql, siteId, query)); }) + .get("/groups", async (c) => { + const siteId = param(c, "siteId"); + const ids = groupIds(c); + const store = c.get("store"); + if (!(await store.getSite(siteId))) throw notFound("site not found"); + const members = await getGroupMembers(store.sql, siteId, ids); + return c.json({ + groups: Object.fromEntries([...members].map(([g, list]) => [g, list.map(referenceCard)])), + }); + }) .get("/:entryId/references", async (c) => { const store = c.get("store"); const entry = await store.getContentEntry(param(c, "entryId")); diff --git a/packages/api/src/scopes.ts b/packages/api/src/scopes.ts index be6f6190..b0e4944e 100644 --- a/packages/api/src/scopes.ts +++ b/packages/api/src/scopes.ts @@ -174,6 +174,7 @@ export const ROUTE_SCOPES: readonly (readonly [Method, string, RouteRequirement] ["POST", `${S}/entries/preview-fragment`, "content:read"], // RFC-0045 §5: the entry picker and an entry's referrers ["GET", `${S}/entries/pick`, "content:read"], + ["GET", `${S}/entries/groups`, "content:read"], ["GET", `${S}/entries/:entryId/references`, "content:read"], ["GET", `${S}/entries/:entryId`, "content:read"], ["PUT", `${S}/entries/:entryId`, "content:draft:own"], diff --git a/packages/api/src/testing-references.ts b/packages/api/src/testing-references.ts index 7b5044b0..39dcb65a 100644 --- a/packages/api/src/testing-references.ts +++ b/packages/api/src/testing-references.ts @@ -119,6 +119,38 @@ export function describeReferencesApi(label: string, factory: ApiStoreFactory): ).toBe(404); }); + it("answers chosen groups' members in this site, any status, and nothing for a gone one", async () => { + const hall = await entry("places", { title: "Main Hall", status: "archived" }); + const salon = await entry("places", { + title: "Ana salon", + locale: "tr", + slug: `${hall.slug}-tr`, + translation: { + group: hall.id, + method: "human", + source: { id: hall.id, locale: hall.locale, rev: hall.rev, hash: await nodeHash(hall) }, + }, + }); + const gone = "entry_01HZX8K3QGONE1"; + const res = await call(`/api/sites/${site.id}/entries/groups?ids=${hall.id},${gone}`); + expect(res.status, JSON.stringify(res.body)).toBe(200); + const groups = res.body.groups as Record; + expect(groups[hall.id]?.map((m) => [m.id, m.status])).toEqual([ + [hall.id, "archived"], + [salon.id, "draft"], + ]); + expect(groups[gone]).toEqual([]); + expect((await call(`/api/sites/${site.id}/entries/groups`)).status).toBe(400); + expect((await call(`/api/sites/${site.id}/entries/groups?ids=nope`)).status).toBe(400); + const many = Array.from({ length: 101 }, (_, i) => `entry_${String(i).padStart(8, "0")}`); + expect( + (await call(`/api/sites/${site.id}/entries/groups?ids=${many.join(",")}`)).status, + ).toBe(400); + expect( + (await call(`/api/sites/site_01HZX8K3QNONE/entries/groups?ids=${hall.id}`)).status, + ).toBe(404); + }); + it("folds case and diacritics on both sides, Turkish İ and ı included", async () => { const cicek = await entry("places", { title: "Çiçek Pasajı", slug: "cicek-pasaji" }); const kongre = await entry("places", { title: "İstanbul Kongre", slug: "kongre" }); diff --git a/packages/core-ts/src/fields/types.ts b/packages/core-ts/src/fields/types.ts index a9113675..15c12c6e 100644 --- a/packages/core-ts/src/fields/types.ts +++ b/packages/core-ts/src/fields/types.ts @@ -207,7 +207,11 @@ export const jsonBytes = (value: unknown): number => encoder.encode(JSON.stringify(value) ?? "").length; /** A label in `locale`, then `defaultLocale`, then any, else the key. */ -export function fieldLabel(def: FieldDefinition, locale: string, defaultLocale: string): string { +export function fieldLabel( + def: Pick, + locale: string, + defaultLocale: string, +): string { const labels = def.labels; return ( labels[locale]?.label ?? @@ -219,7 +223,7 @@ export function fieldLabel(def: FieldDefinition, locale: string, defaultLocale: /** A field's help text in `locale`, then `defaultLocale` (no fallback to other locales). */ export function fieldHelp( - def: FieldDefinition, + def: Pick, locale: string, defaultLocale: string, ): string | undefined { @@ -228,7 +232,7 @@ export function fieldHelp( /** An option's words in `locale`, then `defaultLocale`, then any, else its value. */ export function optionLabel( - def: FieldDefinition, + def: FieldDefinition | SubFieldDefinition, value: string, locale: string, defaultLocale: string, diff --git a/packages/ui/src/blocks/EntryFieldsForm.tsx b/packages/ui/src/blocks/EntryFieldsForm.tsx index 9481c3e6..77ecf227 100644 --- a/packages/ui/src/blocks/EntryFieldsForm.tsx +++ b/packages/ui/src/blocks/EntryFieldsForm.tsx @@ -1,71 +1,42 @@ -import { - Badge, - Button, - Checkbox, - Input, - InputArea, - Link, - Select, - Switch, - Text, -} from "@cloudflare/kumo"; import type { FieldDefinition } from "@cloudhal/core-contracts"; -import { fieldHelp, fieldLabel, optionLabel } from "@cloudhal/core-ts/fields"; -import { type ReactNode, useEffect, useState } from "react"; -import { DateTimeField, type DateTimeFieldLabels, type DateTimeValue } from "./DateTimeField.tsx"; +import { fieldLabel } from "@cloudhal/core-ts/fields"; +import { type ReactNode, useMemo } from "react"; +import { EntryRepeater, type EntryRepeaterLabels } from "./EntryRepeater.tsx"; import { + errorUnder, + type FallbackContext, type FieldValues, fallbackText, hasValue, - instantOf, + type RepeaterErrors, readField, + repeaterErrors, writeField, } from "./entry-fields-model.ts"; -import { wallClockOf, zonedToInstant } from "./zoned-time.ts"; +import { + describeField, + FieldControl, + type FieldControlContext, + type FieldControlLabels, + type FieldImageSlot, + type FieldReferenceSlot, + type FieldRichTextSlot, + HAS_PLACEHOLDER, +} from "./FieldControl.tsx"; + +export type { + AnyFieldDefinition, + FieldImageSlot, + FieldReferenceSlot, + FieldRichTextSlot, +} from "./FieldControl.tsx"; /** The form's own words; the fields' words come from their definitions (`labels`). */ -export interface EntryFieldsFormLabels { - /** The marker of a `requiredToPublish` field: "Needed to publish". */ - neededToPublish: string; - /** Under a non-localized field of a translation: "From the original (English)". */ - fromOriginal: string; - /** The link to the original next to it: "Open the original". */ - openOriginal: string; - /** Under an empty localized field whose control has no placeholder: "The original: {value}". */ - originalValue: (text: string) => string; - yes: string; - no: string; - /** A select's empty choice: "No value". */ - noValue: string; - /** Removes a date or a date and time: "Clear". */ - clear: string; - /** What an image of the original is called in a fallback line: "The original's image". */ - originalImage: string; - /** The date and time controls' words (`DateTimeField`). */ - date: DateTimeFieldLabels; -} - -/** What the host renders for a rich text field (Studio: the lazy markdown body editor). */ -export interface FieldRichTextSlot { - def: FieldDefinition; - label: string; - description: ReactNode; - error: string | undefined; - value: string; - onChange: (value: string) => void; - readOnly: boolean; - placeholder: string | undefined; -} - -/** What the host renders for an image field (Studio: the media picker); the value is an asset id. */ -export interface FieldImageSlot { - def: FieldDefinition; - label: string; - description: ReactNode; - error: string | undefined; - value: string | undefined; - onChange: (assetId: string | undefined) => void; - readOnly: boolean; +export interface EntryFieldsFormLabels extends FieldControlLabels, EntryRepeaterLabels { + /** Under a list shared by every language (a repeater that is not localized). */ + sharedList: string; + /** The original's items in a fallback line when they have no names: "3 items". */ + items: (count: number) => string; } /** The translation group's original, when the entry is a translation in the same site. */ @@ -84,358 +55,149 @@ export interface EntryFieldsFormProps { /** The Studio language (labels, numbers, dates) and the site's default (label fallback). */ locale: string; defaultLocale: string; - /** Worded problems by field key (from the API's `details.issues`). */ + /** + * Worded problems by path under `fields` (from the API's `details.issues`): `venue`, + * `startsAt/at`, or in a repeater `speakers/2` and `speakers/2/person`. + */ errors?: Readonly>; /** A field's limits in words ("Up to 200 characters"), shown after its help. */ hint?: (def: FieldDefinition) => string | undefined; original?: EntryFieldsOriginal | undefined; + /** The site has more than one language: shared lists say so. */ + multilingual?: boolean; /** The zone a new date and time is entered in (the site's), and how a zone reads. */ timeZone: string; zoneLabel: (timeZone: string) => string; labels: EntryFieldsFormLabels; renderRichText?: (slot: FieldRichTextSlot) => ReactNode; renderImage?: (slot: FieldImageSlot) => ReactNode; + /** RFC-0045: a reference's picker (Studio: `ReferenceList` and `EntryPicker`). */ + renderReference?: (slot: FieldReferenceSlot) => ReactNode; + /** A target's title, when the host knows it (repeater legends, a translation's fallback line). */ + referenceTitle?: (groupId: string) => string | undefined; + /** After a repeater item is removed: the host offers `undo` (Studio: a toast). */ + onItemRemoved?: (removed: { field: string; legend: string; undo: () => void }) => void; disabled?: boolean; } -/** One field as its control sees it. */ -interface Ctl { - def: FieldDefinition; - label: string; - description: ReactNode; - error: string | undefined; - value: unknown; - set: (v: unknown) => void; - readOnly: boolean; - /** The original's value as text, for an empty localized field of a translation. */ - placeholder: string | undefined; - props: EntryFieldsFormProps; -} - -const str = (v: unknown) => (typeof v === "string" ? v : ""); - -function TextControl({ def, label, description, error, value, set, readOnly, placeholder }: Ctl) { - const common = { - className: "w-full", - label, - description, - error, - value: str(value), - placeholder, - disabled: readOnly, - onValueChange: set, - }; - if (def.type === "text" && def.multiline) - return ; - return ( - - ); -} - -function NumberControl({ def, label, description, error, value, set, readOnly, placeholder }: Ctl) { - const integer = def.type === "number" && def.integer === true; - return ( - - set(raw.trim() === "" ? undefined : Number.isFinite(Number(raw)) ? Number(raw) : raw) - } - /> - ); -} - -function BooleanControl({ label, description, value, set, readOnly }: Ctl) { - return ( -
- set(on || undefined)} - /> - {description && ( - - {description} - - )} -
- ); -} - -function SelectControl(c: Ctl) { - const { def, label, description, error, value, set, readOnly, placeholder, props } = c; - if (def.type !== "select") return null; - const words = (v: string) => optionLabel(def, v, props.locale, props.defaultLocale); - if (def.multiple) { - const chosen = Array.isArray(value) ? value.map(String) : []; - return ( - o.value)} - onValueChange={(next: string[]) => - set(def.options.map((o) => o.value).filter((v) => next.includes(v))) - } - > - {def.options.map((o) => ( - - ))} - - ); - } - return ( - - ); -} - /** - * RFC-0045: reference and repeater fields until their blocks ship (rollout step 6). Both types are - * refused on every write path until then, so this only shows a stored value read-only, as JSON, - * and never writes. + * What one field shows: its own value, or on a translation the original's (read-only when the + * field is shared), the original's value in words for an empty localized one, and the description. */ -function StoredValueControl({ label, description, error, value }: Ctl) { - return ( - - ); -} - -const CONTROLS: Record ReactNode> = { - text: TextControl, - url: TextControl, - richText: RichTextControl, - number: NumberControl, - boolean: BooleanControl, - date: DateControl, - datetime: DateControl, - image: ImageControl, - select: SelectControl, - reference: StoredValueControl, - repeater: StoredValueControl, -}; - -/** Placeholders only fit controls that show text; the rest say it under the control. */ -const HAS_PLACEHOLDER = new Set(["text", "url", "number", "richText"]); - -/** The description: "Needed to publish", help, limits, and where a translation's value comes from. */ -function describe( +function fieldState( def: FieldDefinition, props: EntryFieldsFormProps, - extra: { inherited: boolean; fallback: string | undefined }, -): ReactNode { - const { labels, locale, defaultLocale, original } = props; - const help = fieldHelp(def, locale, defaultLocale); - const hint = props.hint?.(def); - const required = def.type !== "boolean" && def.requiredToPublish === true; - const words = [help, hint].filter(Boolean).join(" "); + context: FieldControlContext, + fallbackContext: FallbackContext, +) { + const { original } = props; + const inherited = original !== undefined && !def.localized; + const own = readField(props.value, def); + const theirs = original ? readField(original.values, def) : undefined; + const emptyLocalized = original !== undefined && def.localized && !hasValue(own); const fallback = - extra.fallback && !HAS_PLACEHOLDER.has(def.type) ? labels.originalValue(extra.fallback) : ""; - if (!required && !words && !extra.inherited && !fallback) return undefined; - return ( - - {required && ( - - {labels.neededToPublish} - - )} - {words && {words}} - {fallback && {fallback}} - {extra.inherited && original && ( - - {labels.fromOriginal} · {labels.openOriginal} - - )} - - ); + emptyLocalized && hasValue(theirs) ? fallbackText(def, theirs, fallbackContext) : undefined; + const shared = def.type === "repeater" && !def.localized && props.multilingual === true; + const description = describeField(def, context, { + hint: props.hint?.(def), + fallback, + inherited: inherited && original ? { href: original.href } : undefined, + note: shared ? props.labels.sharedList : undefined, + }); + return { + inherited, + shown: inherited ? theirs : own, + fallback, + fallbackValue: emptyLocalized ? theirs : undefined, + description, + }; } /** - * An entry's typed custom fields (RFC-0041 §7, DESIGN.md §2ah): one Kumo control per type — text - * (`Input`, `InputArea` when multiline), rich text (the host's markdown editor, else a markdown - * `InputArea`), number, boolean (`Switch`), date and date-time (`DateTimeField`, the lazy - * `DatePicker` in the Studio locale; date-times in their own zone), URL, image (the host's media - * picker) and select (`Select`, a `Checkbox` group when multiple). Labels and help come from the - * definitions in the Studio language; required fields carry "Needed to publish"; errors show on - * their field. On a translation, non-localized fields show the original's value read-only with a - * link to it, and empty localized ones show the original's value as their placeholder. + * An entry's typed custom fields (RFC-0041 §7, RFC-0045 §5, DESIGN.md §2ah): one `FieldControl` + * per field, and `RepeaterField` items for repeaters, their sub-fields at item paths. Labels and + * help come from the definitions in the Studio language; required fields carry "Needed to + * publish"; errors show on their control by path. On a translation, non-localized fields show the + * original's value read-only with a link to it, and empty localized ones show the original's + * value as their placeholder. */ export function EntryFieldsForm(props: EntryFieldsFormProps) { - const { definitions, value, onChange, locale, defaultLocale, original, labels } = props; - const context = { + const { definitions, value, onChange, locale, defaultLocale, labels } = props; + const context: FieldControlContext = { + locale, + defaultLocale, + timeZone: props.timeZone, + zoneLabel: props.zoneLabel, + labels, + renderRichText: props.renderRichText, + renderImage: props.renderImage, + renderReference: props.renderReference, + }; + const fallbackContext: FallbackContext = { locale, defaultLocale, yes: labels.yes, no: labels.no, image: labels.originalImage, + referenceTitle: props.referenceTitle, + items: labels.items, }; + // One map per error object, so a repeater opens its invalid items once per failed save. + const { errors } = props; + const nested = useMemo(() => { + const out = new Map(); + for (const def of definitions) + if (def.type === "repeater") out.set(def.key, repeaterErrors(errors, def.key)); + return out; + }, [definitions, errors]); return (
{definitions.map((def) => { - const inherited = original !== undefined && !def.localized; - const own = readField(value, def); - const theirs = original ? readField(original.values, def) : undefined; - const fallback = - original && def.localized && !hasValue(own) && hasValue(theirs) - ? fallbackText(def, theirs, context) - : undefined; - const Control = CONTROLS[def.type]; + const { inherited, shown, fallback, fallbackValue, description } = fieldState( + def, + props, + context, + fallbackContext, + ); + const label = fieldLabel(def, locale, defaultLocale); + const readOnly = props.disabled === true || inherited; + const set = (v: unknown) => onChange(writeField(value, def, v)); return (
- onChange(writeField(value, def, v))} - readOnly={props.disabled === true || inherited} - placeholder={fallback && HAS_PLACEHOLDER.has(def.type) ? fallback : undefined} - props={props} - /> + {def.type === "repeater" ? ( + + props.onItemRemoved?.({ field: def.key, legend: r.legend, undo: r.undo }) + : undefined + } + /> + ) : ( + + )}
); })} diff --git a/packages/ui/src/blocks/EntryPicker.tsx b/packages/ui/src/blocks/EntryPicker.tsx new file mode 100644 index 00000000..0bc28c93 --- /dev/null +++ b/packages/ui/src/blocks/EntryPicker.tsx @@ -0,0 +1,364 @@ +import { Badge, Button, cn, Input, LayerDialog, Loader, Text } from "@cloudflare/kumo"; +import { CheckSquare, Square } from "@phosphor-icons/react"; +import { type KeyboardEvent, type ReactElement, useEffect, useId, useRef, useState } from "react"; +import { SectionEmpty } from "./SectionEmpty.tsx"; + +/** One search result (the picker route's card, worded by the host). */ +export interface EntryPickerItem { + /** The translation group id: what a reference stores. */ + id: string; + title: string; + /** The collection's name ("Places"). */ + collection: string; + status: "draft" | "review" | "published" | "archived"; + /** The language's name ("Turkish"). */ + locale?: string | undefined; +} + +/** One answer of `search`: results, and where to continue when there are more. */ +export interface EntryPickerPage { + items: readonly EntryPickerItem[]; + cursor?: string | undefined; + /** The server read its row budget before it found enough: more may match after `cursor`. */ + truncated?: boolean | undefined; +} + +/** Every word of the block (no English fallback). */ +export interface EntryPickerLabels { + title: string; + description?: string | undefined; + /** The search field's label: "Search entries". */ + search: string; + /** Announced and shown under the field: "3 entries found", "No entries found". */ + results: (count: number) => string; + searching: string; + noResults: string; + failed: string; + /** Load the next page: "Show more"; after a truncated read: "Keep searching". */ + more: string; + keepSearching: string; + /** Under a truncated page: "Not every entry was searched yet." */ + truncated: string; + /** Multiple choice: "You can choose 3 more". */ + remaining: (count: number) => string; + /** On a result already chosen, or the entry itself: "Already chosen". */ + unavailable: string; + status: Record; + /** The primary action in multiple choice: "Add 2 entries". */ + add: (count: number) => string; + cancel: string; + close: string; +} + +export interface EntryPickerProps { + /** The button that opens the picker; focus returns to it when the picker closes. */ + trigger: ReactElement; + /** Searches the allowed collections (Studio: the picker route). The block makes no API calls. */ + search: (q: string, cursor: string | undefined) => Promise; + /** Several targets at once (up to `remaining`), or one. */ + multiple: boolean; + remaining: number; + /** Group ids that can't be chosen: already chosen ones, the entry's own group. */ + unavailable?: ReadonlySet | undefined; + onPick: (items: EntryPickerItem[]) => void; + labels: EntryPickerLabels; +} + +const DEBOUNCE_MS = 200; + +const STATUS_BADGE = { + draft: "secondary", + review: "warning", + published: "success", + archived: "outline", +} as const; + +interface Results { + items: EntryPickerItem[]; + cursor?: string | undefined; + truncated: boolean; + state: "loading" | "more" | "done" | "failed"; +} + +const EMPTY: Results = { items: [], truncated: false, state: "loading" }; + +/** Each group once: a group's languages share its id, and the first one found stands for it. */ +function once(items: EntryPickerItem[]): EntryPickerItem[] { + const seen = new Set(); + return items.filter((i) => !seen.has(i.id) && seen.add(i.id) !== undefined); +} + +/** Runs `search` for `q` (debounced) and for "Show more", dropping answers that came too late. */ +function useSearch(open: boolean, q: string, search: EntryPickerProps["search"]) { + const [results, setResults] = useState(EMPTY); + const run = useRef(0); + const fetchPage = (cursor: string | undefined, append: boolean) => { + const mine = ++run.current; + setResults((r) => (append ? { ...r, state: "more" } : { ...EMPTY })); + search(q.trim(), cursor).then( + (page) => { + if (mine !== run.current) return; + setResults((r) => ({ + items: once(append ? [...r.items, ...page.items] : [...page.items]), + cursor: page.cursor, + truncated: page.truncated === true, + state: "done", + })); + }, + () => { + if (mine === run.current) setResults((r) => ({ ...r, state: "failed" })); + }, + ); + }; + // biome-ignore lint/correctness/useExhaustiveDependencies: a new query or opening searches again. + useEffect(() => { + if (!open) return; + const timer = setTimeout(() => fetchPage(undefined, false), q === "" ? 0 : DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [open, q]); + return { results, more: () => fetchPage(results.cursor, true) }; +} + +/** + * Choose entries for a reference (RFC-0045 §5): a `LayerDialog` with a labelled search field over + * a list with combobox semantics (`listbox`, the active option followed with + * `aria-activedescendant`; arrow keys, Home, End and Enter), each result with its title, a + * collection badge, a status badge and its language. The result count is announced in a polite + * live region. One choice picks and closes; several are ticked, then added together, at most + * `remaining`. "Show more" (or "Keep searching" after a truncated read) follows the cursor. The + * block makes no API calls (`search`), and focus returns to `trigger` when it closes. + */ +export function EntryPicker(props: EntryPickerProps) { + const { labels, multiple } = props; + const id = useId(); + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const [active, setActive] = useState(0); + const [chosen, setChosen] = useState([]); + const { results, more } = useSearch(open, q, props.search); + const items = results.items; + + useEffect(() => { + if (!open) return; + setQ(""); + setChosen([]); + setActive(0); + }, [open]); + // biome-ignore lint/correctness/useExhaustiveDependencies: a new query starts at the top. + useEffect(() => setActive(0), [q]); + useEffect(() => setActive((a) => Math.min(a, Math.max(items.length - 1, 0))), [items.length]); + + const isChosen = (item: EntryPickerItem) => chosen.some((c) => c.id === item.id); + const blocked = (item: EntryPickerItem) => + props.unavailable?.has(item.id) === true || + (multiple && !isChosen(item) && chosen.length >= props.remaining); + const choose = (item: EntryPickerItem | undefined) => { + if (!item || blocked(item)) return; + if (!multiple) { + props.onPick([item]); + setOpen(false); + return; + } + setChosen((was) => (isChosen(item) ? was.filter((c) => c.id !== item.id) : [...was, item])); + }; + const onKeyDown = (e: KeyboardEvent) => { + const next = KEY_STEPS[e.key]?.(active, items.length - 1); + if (next !== undefined && items.length > 0) { + e.preventDefault(); + setActive(next); + document.getElementById(`${id}-option-${next}`)?.scrollIntoView?.({ block: "nearest" }); + } else if (e.key === "Enter") { + e.preventDefault(); + choose(items[active]); + } + }; + + const busy = results.state === "loading"; + const said = busy ? labels.searching : labels.results(items.length); + return ( + + + + {labels.title} + {labels.description && ( + {labels.description} + )} + +
+ + + + {said} + + + {busy ? ( +
+ +
+ ) : results.state === "failed" && items.length === 0 ? ( + + {labels.failed} + + ) : items.length === 0 && !results.cursor ? ( + + ) : ( +
+ {items.map((item, i) => ( + { + setActive(i); + choose(item); + }} + /> + ))} +
+ )} + +
+
+ {multiple && ( + + { + props.onPick(chosen); + setOpen(false); + }} + > + {labels.add(chosen.length)} + + + )} +
+
+ ); +} + +/** The active option after a key: arrows step, Home and End jump. */ +const KEY_STEPS: Record number> = { + ArrowDown: (a, last) => Math.min(a + 1, last), + ArrowUp: (a) => Math.max(a - 1, 0), + Home: () => 0, + End: (_, last) => last, +}; + +/** "Show more", or "Keep searching" after a truncated read, and a failed later page. */ +function MoreResults({ + results, + more, + labels, +}: { + results: Results; + more: () => void; + labels: EntryPickerLabels; +}) { + return ( + <> + {results.state !== "loading" && results.cursor && ( +
+ {results.truncated && ( + + {labels.truncated} + + )} + +
+ )} + {results.state === "failed" && results.items.length > 0 && ( + + {labels.failed} + + )} + + ); +} + +function PickerOption(p: { + id: string; + item: EntryPickerItem; + active: boolean; + selected: boolean; + disabled: boolean; + multiple: boolean; + unavailable: boolean; + labels: EntryPickerLabels; + onChoose: () => void; +}) { + const { item, labels } = p; + const Mark = p.selected ? CheckSquare : Square; + return ( + // biome-ignore lint/a11y/useKeyWithClickEvents: the search field owns the keyboard (aria-activedescendant). +
e.preventDefault()} + onClick={p.onChoose} + className={cn( + "flex min-w-0 cursor-pointer items-start gap-2 rounded-md px-3 py-2", + p.active && "bg-kumo-tint", + p.disabled && "cursor-not-allowed opacity-60", + )} + > + {p.multiple && ( + + )} + + + {item.title} + + + {item.collection} + {labels.status[item.status]} + {item.locale && ( + + {item.locale} + + )} + {p.unavailable && ( + + {labels.unavailable} + + )} + + +
+ ); +} diff --git a/packages/ui/src/blocks/EntryRepeater.tsx b/packages/ui/src/blocks/EntryRepeater.tsx new file mode 100644 index 00000000..f526cffa --- /dev/null +++ b/packages/ui/src/blocks/EntryRepeater.tsx @@ -0,0 +1,71 @@ +import type { RepeaterField as RepeaterDefinition } from "@cloudhal/core-contracts"; +import { FIELD_LIMITS, fieldLabel } from "@cloudhal/core-ts/fields"; +import type { ReactNode } from "react"; +import { itemName, type RepeaterErrors, repeaterItems } from "./entry-fields-model.ts"; +import { describeField, FieldControl, type FieldControlContext } from "./FieldControl.tsx"; +import { RepeaterField, type RepeaterFieldLabels } from "./RepeaterField.tsx"; + +/** The words a repeater adds to the form's. */ +export interface EntryRepeaterLabels { + repeater: RepeaterFieldLabels; + /** An item's legend, an ICU message: "Item 2: Ada Lovelace", or "Item 2" without a name. */ + itemLegend: (index: number, name: string | undefined) => string; +} + +export interface EntryRepeaterProps { + def: RepeaterDefinition; + label: string; + description: ReactNode; + errors: RepeaterErrors; + value: unknown; + onChange: (items: unknown) => void; + readOnly: boolean; + context: FieldControlContext; + labels: EntryRepeaterLabels; + referenceTitle?: ((groupId: string) => string | undefined) | undefined; + onRemoved?: ((removed: { index: number; legend: string; undo: () => void }) => void) | undefined; +} + +/** + * A repeater field of `EntryFieldsForm` (RFC-0045 §5): `RepeaterField` with each sub-field as a + * `FieldControl` at its item path (`speakers/2/person`), so images use the host's picker and + * references the host's `ReferenceList` and `EntryPicker`. Legends name items by their label + * sub-field (`itemLabel`, else the first text, select or reference). + */ +export function EntryRepeater(props: EntryRepeaterProps) { + const { def, context } = props; + const names = { + locale: context.locale, + defaultLocale: context.defaultLocale, + referenceTitle: props.referenceTitle, + }; + return ( + props.labels.itemLegend(i + 1, itemName(def, item, names))} + onRemoved={props.onRemoved} + labels={props.labels.repeater} + renderControl={({ sub, index, value, onChange, error, readOnly }) => ( + + )} + /> + ); +} diff --git a/packages/ui/src/blocks/FieldControl.tsx b/packages/ui/src/blocks/FieldControl.tsx new file mode 100644 index 00000000..1305e37b --- /dev/null +++ b/packages/ui/src/blocks/FieldControl.tsx @@ -0,0 +1,498 @@ +import { + Badge, + Button, + Checkbox, + Input, + InputArea, + Link, + Select, + Switch, + Text, +} from "@cloudflare/kumo"; +import type { + FieldDefinition, + ReferenceField, + ReferenceSubField, + SubFieldDefinition, +} from "@cloudhal/core-contracts"; +import { FIELD_LIMITS, fieldHelp, optionLabel } from "@cloudhal/core-ts/fields"; +import { type ReactNode, useEffect, useId, useState } from "react"; +import { DateTimeField, type DateTimeFieldLabels, type DateTimeValue } from "./DateTimeField.tsx"; +import { hasValue, instantOf, referenceIds } from "./entry-fields-model.ts"; +import { wallClockOf, zonedToInstant } from "./zoned-time.ts"; + +/** A field or a repeater's sub-field: what one control edits. */ +export type AnyFieldDefinition = FieldDefinition | SubFieldDefinition; + +/** The controls' own words; the fields' words come from their definitions (`labels`). */ +export interface FieldControlLabels { + /** The marker of a `requiredToPublish` field: "Needed to publish". */ + neededToPublish: string; + /** Under a non-localized field of a translation: "From the original (English)". */ + fromOriginal: string; + /** The link to the original next to it: "Open the original". */ + openOriginal: string; + /** Under an empty localized field whose control has no placeholder: "The original: {value}". */ + originalValue: (text: string) => string; + yes: string; + no: string; + /** A select's empty choice: "No value". */ + noValue: string; + /** Removes a date or a date and time: "Clear". */ + clear: string; + /** What an image of the original is called in a fallback line: "The original's image". */ + originalImage: string; + /** The date and time controls' words (`DateTimeField`). */ + date: DateTimeFieldLabels; +} + +/** What the host renders for a rich text field (Studio: the lazy markdown body editor). */ +export interface FieldRichTextSlot { + def: AnyFieldDefinition; + label: string; + description: ReactNode; + error: string | undefined; + value: string; + onChange: (value: string) => void; + readOnly: boolean; + placeholder: string | undefined; +} + +/** What the host renders for an image field (Studio: the media picker); the value is an asset id. */ +export interface FieldImageSlot { + def: AnyFieldDefinition; + label: string; + description: ReactNode; + error: string | undefined; + value: string | undefined; + onChange: (assetId: string | undefined) => void; + readOnly: boolean; +} + +/** + * What the host renders for a reference (RFC-0045 §5; Studio: `ReferenceList` and `EntryPicker` + * around the picker route), inside the block's `fieldset`, which carries the label, the + * description and the error. Values are translation group ids, in order. + */ +export interface FieldReferenceSlot { + def: ReferenceField | ReferenceSubField; + /** The value's path under `fields`: `venue`, or `speakers/2/person` in a repeater item. */ + path: string; + label: string; + description: ReactNode; + error: string | undefined; + value: readonly string[]; + /** A list of targets (`multiple`), or one. */ + multiple: boolean; + /** At most this many targets (1 unless `multiple`). */ + max: number; + onChange: (ids: readonly string[]) => void; + readOnly: boolean; + /** + * An empty localized field of a translation: the original's targets. The form words them under + * the control through `referenceTitle`; the host loads them so it can. + */ + fallback: readonly string[] | undefined; +} + +/** Everything a control needs besides its own field. */ +export interface FieldControlContext { + /** The Studio language (labels, numbers, dates) and the site's default (label fallback). */ + locale: string; + defaultLocale: string; + /** The zone a new date and time is entered in (the site's), and how a zone reads. */ + timeZone: string; + zoneLabel: (timeZone: string) => string; + labels: FieldControlLabels; + renderRichText?: ((slot: FieldRichTextSlot) => ReactNode) | undefined; + renderImage?: ((slot: FieldImageSlot) => ReactNode) | undefined; + renderReference?: ((slot: FieldReferenceSlot) => ReactNode) | undefined; +} + +export interface FieldControlProps { + def: AnyFieldDefinition; + /** The value's path under `fields` (`venue`, `speakers/2/person`). */ + path: string; + label: string; + description: ReactNode; + error: string | undefined; + value: unknown; + onChange: (v: unknown) => void; + readOnly: boolean; + /** The original's value as text, for an empty localized field of a translation. */ + placeholder?: string | undefined; + /** The original's value itself, for controls that show it their own way (references). */ + fallbackValue?: unknown; + context: FieldControlContext; +} + +type Ctl = FieldControlProps; + +const str = (v: unknown) => (typeof v === "string" ? v : ""); + +function TextControl({ + def, + label, + description, + error, + value, + onChange, + readOnly, + placeholder, +}: Ctl) { + const common = { + className: "w-full", + label, + description, + error, + value: str(value), + placeholder, + disabled: readOnly, + onValueChange: onChange, + }; + if (def.type === "text" && def.multiline) + return ; + return ( + + ); +} + +function NumberControl(c: Ctl) { + const { def, label, description, error, value, onChange, readOnly, placeholder } = c; + const integer = def.type === "number" && def.integer === true; + return ( + + onChange(raw.trim() === "" ? undefined : Number.isFinite(Number(raw)) ? Number(raw) : raw) + } + /> + ); +} + +function BooleanControl({ label, description, value, onChange, readOnly }: Ctl) { + return ( +
+ onChange(on || undefined)} + /> + {description && ( + + {description} + + )} +
+ ); +} + +function SelectControl(c: Ctl) { + const { def, label, description, error, value, onChange, readOnly, placeholder, context } = c; + if (def.type !== "select") return null; + const words = (v: string) => optionLabel(def, v, context.locale, context.defaultLocale); + if ("multiple" in def && def.multiple) { + const chosen = Array.isArray(value) ? value.map(String) : []; + return ( + o.value)} + onValueChange={(next: string[]) => + onChange(def.options.map((o) => o.value).filter((v) => next.includes(v))) + } + > + {def.options.map((o) => ( + + ))} + + ); + } + return ( + + ); +} + +/** A stored value read-only, as JSON: a reference without a host picker, or a value of no known shape. */ +function StoredValueControl({ label, description, error, value }: Ctl) { + return ( + + ); +} + +/** RFC-0045: a reference through the host's picker; one id is stored bare, several as a list. */ +function ReferenceControl(c: Ctl) { + const about = useId(); + const { def } = c; + if (def.type !== "reference" || !c.context.renderReference) return StoredValueControl(c); + const multiple = "multiple" in def && def.multiple === true; + const max = multiple ? ("maxItems" in def ? def.maxItems : undefined) : 1; + const fallback = referenceIds(c.fallbackValue); + const slot: FieldReferenceSlot = { + def, + path: c.path, + label: c.label, + description: c.description, + error: c.error, + value: referenceIds(c.value), + multiple, + max: max ?? FIELD_LIMITS.listItems, + onChange: (ids) => c.onChange(multiple ? [...ids] : ids[0]), + readOnly: c.readOnly, + fallback: fallback.length ? fallback : undefined, + }; + return ( +
+ {/* Kumo's own label style (`Field`, `Checkbox.Group`). */} + {c.label} + {c.context.renderReference(slot)} + {(c.description || c.error) && ( + + {c.description && ( + + {c.description} + + )} + {c.error && ( + + {c.error} + + )} + + )} +
+ ); +} + +const CONTROLS: Record ReactNode> = { + text: TextControl, + url: TextControl, + richText: RichTextControl, + number: NumberControl, + boolean: BooleanControl, + date: DateControl, + datetime: DateControl, + image: ImageControl, + select: SelectControl, + reference: ReferenceControl, + // A repeater renders through `RepeaterField` (EntryFieldsForm); never as a sub-field. + repeater: StoredValueControl, +}; + +/** Placeholders only fit controls that show text; the rest say it under the control. */ +export const HAS_PLACEHOLDER: ReadonlySet = new Set(["text", "url", "number", "richText"]); + +/** What a description says besides the field's own help. */ +export interface DescribeExtra { + /** A limit in words ("Up to 80 characters."), after the help. */ + hint?: string | undefined; + /** The original's value in words, for a control without a placeholder. */ + fallback?: string | undefined; + /** The value is the original's: "From the original · Open the original". */ + inherited?: { href: string } | undefined; + /** One more line, such as a shared list's "Not translated" note. */ + note?: string | undefined; +} + +/** A field's description: "Needed to publish", help, limits, and where a translation's value comes from. */ +export function describeField( + def: AnyFieldDefinition, + context: Pick, + extra: DescribeExtra = {}, +): ReactNode { + const { labels } = context; + const help = fieldHelp(def, context.locale, context.defaultLocale); + const required = def.type !== "boolean" && def.requiredToPublish === true; + const words = [help, extra.hint, extra.note].filter(Boolean).join(" "); + const fallback = + extra.fallback && !HAS_PLACEHOLDER.has(def.type) ? labels.originalValue(extra.fallback) : ""; + if (!required && !words && !extra.inherited && !fallback) return undefined; + return ( + + {required && ( + + {labels.neededToPublish} + + )} + {words && {words}} + {fallback && {fallback}} + {extra.inherited && ( + + {labels.fromOriginal} · {labels.openOriginal} + + )} + + ); +} + +/** + * One typed value's Kumo control (RFC-0041 §7, RFC-0045 §5): text (`Input`, `InputArea` when + * multiline), rich text (the host's markdown editor, else a markdown `InputArea`), number, boolean + * (`Switch`), date and date-time (`DateTimeField`), URL, image (the host's media picker), select + * (`Select`, a `Checkbox` group when multiple) and reference (the host's picker, else the stored + * ids read-only). `EntryFieldsForm` uses it for fields, `RepeaterField` items for sub-fields. + */ +export function FieldControl(props: FieldControlProps) { + const Control = CONTROLS[props.def.type]; + return ; +} diff --git a/packages/ui/src/blocks/ReferenceList.tsx b/packages/ui/src/blocks/ReferenceList.tsx new file mode 100644 index 00000000..a9321f42 --- /dev/null +++ b/packages/ui/src/blocks/ReferenceList.tsx @@ -0,0 +1,210 @@ +import { Badge, Button, DropdownMenu, Link, SkeletonLine, Text } from "@cloudflare/kumo"; +import { ArrowDown, ArrowSquareOut, ArrowUp, DotsThree, Trash } from "@phosphor-icons/react"; +import { directional } from "./DirectionalIcon.tsx"; +import { InsetPanel } from "./InsetPanel.tsx"; + +/** A target's state as the list shows it; `missing` is a stored id with no entry left. */ +export type ReferenceTargetStatus = "draft" | "review" | "published" | "archived" | "missing"; + +/** One chosen target, in stored order. */ +export interface ReferenceListItem { + /** The stored translation group id. */ + id: string; + /** Absent while it loads. */ + title?: string | undefined; + /** "Places · Turkish". */ + meta?: string | undefined; + /** Absent while it loads. */ + status?: ReferenceTargetStatus | undefined; + /** The target's editor. */ + href?: string | undefined; +} + +/** Every word of the block (no English fallback). */ +export interface ReferenceListLabels { + draft: string; + review: string; + archived: string; + /** A dangling id: "Not found". */ + notFound: string; + /** Next to a target that is not published: "Will not show until published". */ + notShown: string; + /** Next to a dangling id: "This entry was deleted. Remove it or choose another." */ + notFoundHint: string; + /** Accessible names per row: "Move Main hall up", "Actions for Main hall". */ + moveUp: (title: string) => string; + moveDown: (title: string) => string; + actions: (title: string) => string; + /** The menu's items. */ + open: string; + menuMoveUp: string; + menuMoveDown: string; + remove: string; + /** Read while loading (the row's accessible name). */ + loading: string; +} + +export interface ReferenceListProps { + /** The list's accessible name (the field's label). */ + label: string; + items: readonly ReferenceListItem[]; + /** Up and down for a list of several targets. */ + sortable?: boolean; + readOnly?: boolean; + onMove?: (index: number, by: -1 | 1) => void; + onRemove?: (index: number) => void; + labels: ReferenceListLabels; +} + +const STATUS_BADGE = { + draft: "secondary", + review: "warning", + archived: "outline", + missing: "error", +} as const; + +/** + * The chosen targets of a reference (RFC-0045 §5), in stored order, as `InsetPanel` rows: the + * title (a link to its editor), the collection and language, and a "Draft", "In review" or + * "Archived" badge with "Will not show until published"; "Not found" for an id with no entry left. + * Per row: move up and down (icon buttons from `sm` when sortable, also in the ⋯ menu), open and + * remove. Badges wrap under the title on narrow screens. + */ +export function ReferenceList({ + label, + items, + sortable = false, + readOnly = false, + onMove, + onRemove, + labels, +}: ReferenceListProps) { + return ( +
    + {items.map((item, i) => { + const name = item.title ?? item.id; + const status = item.status; + const shown = status === "published"; + const first = i === 0; + const last = i === items.length - 1; + return ( +
  • + {/* The actions stay on the title's line; badges wrap under the title. */} + + + {status === undefined ? ( + + + + ) : status === "missing" ? ( + + {item.id} + + ) : item.href ? ( + + {name} + + ) : ( + + {name} + + )} + + {item.meta && status !== "missing" && ( + + {item.meta} + + )} + {status !== undefined && status !== "published" && ( + + {status === "missing" ? labels.notFound : labels[status]} + + )} + {status !== undefined && !shown && ( + + {status === "missing" ? labels.notFoundHint : labels.notShown} + + )} + + + {!readOnly && ( + + {sortable && ( + +
  • + ); + })} +
+ ); +} diff --git a/packages/ui/src/blocks/RepeaterField.tsx b/packages/ui/src/blocks/RepeaterField.tsx new file mode 100644 index 00000000..45409763 --- /dev/null +++ b/packages/ui/src/blocks/RepeaterField.tsx @@ -0,0 +1,317 @@ +import { Button, Collapsible, DropdownMenu, Text } from "@cloudflare/kumo"; +import { + ArrowDown, + ArrowUp, + CaretDown, + CaretRight, + Copy, + DotsThree, + Plus, + Trash, +} from "@phosphor-icons/react"; +import { type ReactNode, useId } from "react"; +import { DirectionalIcon } from "./DirectionalIcon.tsx"; +import type { FieldValues } from "./entry-fields-model.ts"; +import { useRepeater, withSub } from "./repeater-state.ts"; +import { SectionEmpty } from "./SectionEmpty.tsx"; + +/** Every word of the block (no English fallback). Item numbers are 1-based. */ +export interface RepeaterFieldLabels { + /** "Add item". */ + add: string; + /** The count next to it: "3 of 20". */ + count: (count: number, max: number) => string; + /** No items yet: "No items yet". */ + empty: string; + /** Icon buttons and the menu's accessible name, per item: "Move item 2 up". */ + moveUp: (index: number) => string; + moveDown: (index: number) => string; + actions: (index: number) => string; + /** The menu's items: "Move up", "Move down", "Duplicate", "Remove". */ + menuMoveUp: string; + menuMoveDown: string; + duplicate: string; + remove: string; + /** Announced (polite): "Item 2 moved to position 1", "Item 2 removed". */ + moved: (from: number, to: number) => string; + removed: (index: number) => string; + /** Above the items when some collapsed ones have problems: "2 items need attention". */ + invalidItems: (count: number) => string; +} + +/** One item's problems: its own, and per sub-field key. */ +export interface RepeaterItemErrors { + item?: string | undefined; + subs: ReadonlyMap; +} + +/** What `renderControl` gets for one sub-field of one item. */ +export interface RepeaterControlArgs { + sub: S; + item: FieldValues; + /** 0-based. */ + index: number; + value: unknown; + onChange: (v: unknown) => void; + error: string | undefined; + readOnly: boolean; +} + +export interface RepeaterFieldProps { + /** The field's label, its description and its own problem. */ + label: string; + description?: ReactNode; + error?: string | undefined; + items: readonly FieldValues[]; + onChange: (items: FieldValues[]) => void; + /** The sub-fields of every item, in form order (stacked). */ + subFields: readonly S[]; + /** One sub-field's control (Studio: `FieldControl`, images through `FieldImage`). */ + renderControl: (args: RepeaterControlArgs) => ReactNode; + /** An item's legend, from an ICU message: "Item 2: Ada Lovelace". */ + legend: (index: number, item: FieldValues) => string; + /** At most this many items. */ + max: number; + readOnly?: boolean; + /** Problems by item index (0-based); a new map opens the items it names. */ + itemErrors?: ReadonlyMap | undefined; + /** After "Remove": the host offers `undo` (Studio: a toast), which puts the item back. */ + onRemoved?: (removed: { index: number; legend: string; undo: () => void }) => void; + labels: RepeaterFieldLabels; +} + +/** + * A repeater (RFC-0045 §5): an ordered list of items, each a `fieldset` whose `legend` names it, + * with its sub-fields stacked and folded in a `Collapsible`. "Add item" with the count ("3 of + * 20"); per item, move up and down (icon buttons from `sm`, also in the ⋯ menu), duplicate and + * remove. Moving is done with buttons only. After "Add item" focus goes to the new item's first + * control; after "Remove" to the next item's toggle, else "Add item", and the host gets an undo. + * Moves and removals are announced in a polite live region. Items with problems open when a new + * `itemErrors` arrives; collapsed ones are listed above the items. + */ +export function RepeaterField(props: RepeaterFieldProps) { + const { items, max, labels, readOnly = false } = props; + const id = useId(); + const { root, keys, latest, open, said, toggle, add, move, duplicate, remove, reveal } = + useRepeater(props); + const collapsedInvalid = [...(props.itemErrors?.keys() ?? [])] + .filter((i) => i < items.length && !open.has(keys.current[i] as string)) + .sort((a, b) => a - b); + const full = items.length >= max; + return ( +
+ {/* Kumo's own label style (`Field`, `Checkbox.Group`). */} + {props.label} + {(props.description || props.error) && ( +
+ {props.description && ( + + {props.description} + + )} + {props.error && ( + + {props.error} + + )} +
+ )} + {collapsedInvalid.length > 0 && ( +
+ + {labels.invalidItems(collapsedInvalid.length)} + + {collapsedInvalid.map((i) => ( + + ))} +
+ )} + {items.length === 0 ? ( + + ) : ( +
+ {items.map((item, i) => { + const key = keys.current[i] as string; + return ( + toggle(key, on)} + legend={props.legend(i, item)} + errors={props.itemErrors?.get(i)} + readOnly={readOnly} + full={full} + labels={labels} + onMove={(by) => move(i, by)} + onDuplicate={() => duplicate(i)} + onRemove={() => remove(i)} + > + {props.subFields.map((sub) => ( +
+ {props.renderControl({ + sub, + item, + index: i, + value: item[sub.key], + onChange: (v) => { + const next = [...latest.current]; + next[i] = withSub(next[i] ?? {}, sub.key, v); + props.onChange(next); + }, + error: props.itemErrors?.get(i)?.subs.get(sub.key), + readOnly, + })} +
+ ))} +
+ ); + })} +
+ )} + {!readOnly && ( +
+ + + {labels.count(items.length, max)} + +
+ )} + + {said} + +
+ ); +} + +interface ItemProps { + itemKey: string; + index: number; + count: number; + open: boolean; + onOpen: (open: boolean) => void; + legend: string; + errors: RepeaterItemErrors | undefined; + readOnly: boolean; + full: boolean; + labels: RepeaterFieldLabels; + onMove: (by: -1 | 1) => void; + onDuplicate: () => void; + onRemove: () => void; + children: ReactNode; +} + +/** One item: its legend and toggle, its actions, and its sub-fields in a `Collapsible` panel. */ +function RepeaterItem(p: ItemProps) { + const panel = useId(); + const n = p.index + 1; + const first = p.index === 0; + const last = p.index === p.count - 1; + return ( +
+ {p.legend} +
+ + {!p.readOnly && ( + + +
+ {p.errors?.item && ( + + {p.errors.item} + + )} + + +
+ {p.children} +
+
+
+
+ ); +} diff --git a/packages/ui/src/blocks/UsedByList.tsx b/packages/ui/src/blocks/UsedByList.tsx new file mode 100644 index 00000000..5e526309 --- /dev/null +++ b/packages/ui/src/blocks/UsedByList.tsx @@ -0,0 +1,104 @@ +import { Badge, Link, SkeletonLine, Table, Text } from "@cloudflare/kumo"; + +/** One entry that shows a reference to this one. */ +export interface UsedByRow { + /** A stable key (holder id and field path). */ + id: string; + title: string; + href?: string | undefined; + /** "Events · English · Venue": where the value sits, worded by the host. */ + detail: string; + status: "draft" | "review" | "published" | "archived"; + /** The holder's translations that show its value too, worded: "Also shown in Turkish". */ + shownBy?: string | undefined; + /** In the delete dialog: what the delete does to this entry ("Will show nothing for this field"). */ + note?: string | undefined; +} + +/** Every word of the block (no English fallback). */ +export interface UsedByListLabels { + /** Column headers. */ + entry: string; + status: string; + statuses: Record; + /** The table's accessible caption: "Entries that reference this entry". */ + caption: string; +} + +export interface UsedByListProps { + rows: readonly UsedByRow[]; + loading?: boolean; + labels: UsedByListLabels; +} + +const STATUS_BADGE = { + draft: "secondary", + review: "warning", + published: "success", + archived: "outline", +} as const; + +/** + * The entries that reference this one (RFC-0045 §5): the entry editor's "Used by" card and the + * delete dialog's referrers. A Kumo `Table` of two columns, so it fits a phone: the entry (its + * title linked to its editor, where the value sits, the translations that show it too, and a + * note), and its status as a `Badge`. Published referrers come first from the server. Goes in a + * flush `SectionCard`. + */ +export function UsedByList({ rows, loading = false, labels }: UsedByListProps) { + return ( + + + + + {labels.entry} + {labels.status} + + + + {loading + ? [0, 1, 2].map((r) => ( + + + + + + + + + )) + : rows.map((row) => ( + + +
+ {row.href ? ( + + {row.title} + + ) : ( + {row.title} + )} + + {row.detail} + + {row.shownBy && ( + + {row.shownBy} + + )} + {row.note && ( + + {row.note} + + )} +
+
+ + {labels.statuses[row.status]} + +
+ ))} +
+
{labels.caption}
+ ); +} diff --git a/packages/ui/src/blocks/entry-fields-model.ts b/packages/ui/src/blocks/entry-fields-model.ts index 87ec8cc4..a6824955 100644 --- a/packages/ui/src/blocks/entry-fields-model.ts +++ b/packages/ui/src/blocks/entry-fields-model.ts @@ -3,7 +3,7 @@ * entry's `fields`, through a running field operation's marker, and the words a translation shows * for its original's value. No React, no English: words come from the caller. */ -import type { FieldDefinition } from "@cloudhal/core-contracts"; +import type { FieldDefinition, SubFieldDefinition } from "@cloudhal/core-contracts"; import { optionLabel, renamedFrom } from "@cloudhal/core-ts/fields"; import { dateTimeFormat, formatList, formatNumber } from "@cloudhal/messages/format"; import { formatDay } from "./schedule-model.ts"; @@ -58,13 +58,21 @@ export interface FallbackContext { no: string; /** An image field's words ("The original's image"). */ image: string; + /** RFC-0045: a target's title, when the host knows it. */ + referenceTitle?: ((groupId: string) => string | undefined) | undefined; + /** RFC-0045: a list of items without names ("3 items"). */ + items?: ((count: number) => string) | undefined; } /** * The original's value as one line of text, in the Studio language: what an empty localized field * of a translation shows as its placeholder, or under the control when it has none. */ -export function fallbackText(def: FieldDefinition, v: unknown, ctx: FallbackContext): string { +export function fallbackText( + def: FieldDefinition | SubFieldDefinition, + v: unknown, + ctx: FallbackContext, +): string { if (!hasValue(v) && def.type !== "boolean") return ""; switch (def.type) { case "boolean": @@ -91,7 +99,103 @@ export function fallbackText(def: FieldDefinition, v: unknown, ctx: FallbackCont } case "image": return ctx.image; + case "reference": { + const titles = referenceIds(v).map((id) => ctx.referenceTitle?.(id)); + return titles.every((x) => x !== undefined) ? formatList(ctx.locale, titles as string[]) : ""; + } + case "repeater": { + const items = repeaterItems(v); + const names = items.map((item) => itemName(def, item, ctx)); + if (names.every((x) => x !== undefined)) return formatList(ctx.locale, names as string[]); + return ctx.items?.(items.length) ?? ""; + } default: return typeof v === "string" ? v : JSON.stringify(v); } } + +/** A reference value as a list of group ids: one id, a list of them, or none. */ +export function referenceIds(v: unknown): string[] { + if (typeof v === "string") return v === "" ? [] : [v]; + return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []; +} + +/** A repeater value as its items (anything that is not a flat object is left out). */ +export function repeaterItems(v: unknown): FieldValues[] { + return Array.isArray(v) ? v.filter(isRecord) : []; +} + +type Repeater = Extract; + +/** The sub-field that names an item (RFC-0045 §1): `itemLabel`, else the first text, select or reference. */ +export function itemLabelSub(def: Repeater): SubFieldDefinition | undefined { + const named = (s: SubFieldDefinition) => + s.type === "text" || s.type === "select" || s.type === "reference"; + return def.fields.find((s) => s.key === def.itemLabel && named(s)) ?? def.fields.find(named); +} + +/** What `itemName` needs to word a reference or a choice. */ +export interface ItemNameContext { + locale: string; + defaultLocale: string; + /** A target's title, when the host knows it. */ + referenceTitle?: ((groupId: string) => string | undefined) | undefined; +} + +/** An item's name from its label sub-field ("Ada Lovelace"), or undefined when it has none. */ +export function itemName( + def: Repeater, + item: FieldValues, + ctx: ItemNameContext, +): string | undefined { + const sub = itemLabelSub(def); + const v = sub ? item[sub.key] : undefined; + if (!sub || !hasValue(v) || typeof v !== "string") return undefined; + if (sub.type === "select") return optionLabel(sub, v, ctx.locale, ctx.defaultLocale); + if (sub.type === "reference") return ctx.referenceTitle?.(v); + const line = v.trim().split("\n")[0]?.trim(); + return line || undefined; +} + +/** + * The first message at or under `key` in `errors` (paths relative to `fields`: `venue`, + * `startsAt/at`, `speakers/2/person`): what a field that is not a repeater shows. + */ +export function errorUnder( + errors: Readonly> | undefined, + key: string, +): string | undefined { + if (!errors) return undefined; + if (errors[key] !== undefined) return errors[key]; + const prefix = `${key}/`; + for (const [path, message] of Object.entries(errors)) if (path.startsWith(prefix)) return message; + return undefined; +} + +/** A repeater's messages: its own, and each item's own and per sub-field (`speakers/2/person`). */ +export interface RepeaterErrors { + field: string | undefined; + items: Map }>; +} + +export function repeaterErrors( + errors: Readonly> | undefined, + key: string, +): RepeaterErrors { + const out: RepeaterErrors = { field: errors?.[key], items: new Map() }; + const prefix = `${key}/`; + for (const [path, message] of Object.entries(errors ?? {})) { + if (!path.startsWith(prefix)) continue; + const [index, sub] = path.slice(prefix.length).split("/"); + const i = Number(index); + if (!Number.isInteger(i) || i < 0) { + out.field ??= message; + continue; + } + const slot = out.items.get(i) ?? { subs: new Map() }; + if (sub === undefined || sub === "") slot.item ??= message; + else if (!slot.subs.has(sub)) slot.subs.set(sub, message); + out.items.set(i, slot); + } + return out; +} diff --git a/packages/ui/src/blocks/repeater-state.ts b/packages/ui/src/blocks/repeater-state.ts new file mode 100644 index 00000000..a340a888 --- /dev/null +++ b/packages/ui/src/blocks/repeater-state.ts @@ -0,0 +1,158 @@ +/** + * The state of `RepeaterField` (RFC-0045 §5): stable item keys, which items are open, focus after + * add, move, remove and undo, and the live region's words. No words of its own. + */ +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { type FieldValues, hasValue } from "./entry-fields-model.ts"; +import type { RepeaterFieldLabels, RepeaterItemErrors } from "./RepeaterField.tsx"; + +type Focus = { key: string; at: "first" | "toggle" | "up" | "down" } | { key?: never; at: "add" }; + +const FOCUSABLE = + 'input:not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [contenteditable="true"], [tabindex]:not([tabindex="-1"])'; + +/** `v` set on `item` under `key`, or the key removed when it is no value. */ +export function withSub(item: FieldValues, key: string, v: unknown): FieldValues { + const next: Record = { ...item }; + if (hasValue(v)) next[key] = v; + else delete next[key]; + return next; +} + +/** Stable keys for items that have no ids: kept in step with every add, move and removal. */ +function useItemKeys(length: number) { + const counter = useRef(0); + const keys = useRef([]); + const fresh = () => `item-${counter.current++}`; + if (keys.current.length !== length) keys.current = Array.from({ length }, fresh); + return { keys, fresh }; +} + +/** What `useRepeater` reads of the block's props. */ +export interface RepeaterStateProps { + items: readonly FieldValues[]; + onChange: (items: FieldValues[]) => void; + legend: (index: number, item: FieldValues) => string; + itemErrors?: ReadonlyMap | undefined; + onRemoved?: (removed: { index: number; legend: string; undo: () => void }) => void; + labels: RepeaterFieldLabels; +} + +/** Keys, open items, focus and announcements, and the operations that keep them in step. */ +export function useRepeater(props: RepeaterStateProps) { + const { items, labels } = props; + const root = useRef(null); + const { keys, fresh } = useItemKeys(items.length); + const latest = useRef(items); + latest.current = items; + const [open, setOpen] = useState>(() => + items.length <= 3 ? new Set(keys.current) : new Set(), + ); + const [said, say] = useState(""); + const focus = useRef(null); + + useEffect(() => { + if (!props.itemErrors?.size) return; + setOpen((was) => { + const next = new Set(was); + for (const i of props.itemErrors?.keys() ?? []) { + const key = keys.current[i]; + if (key) next.add(key); + } + return next; + }); + }, [props.itemErrors, keys]); + + useLayoutEffect(() => { + const want = focus.current; + const el = root.current; + if (!want || !el) return; + focus.current = null; + if (want.at === "add") + return void el.querySelector("[data-repeater-add]")?.focus(); + const item = el.querySelector(`[data-repeater-item="${want.key}"]`); + const pick = (selector: string) => item?.querySelector(selector); + const target = + want.at === "first" + ? pick(`[data-repeater-panel] :is(${FOCUSABLE})`) + : want.at === "toggle" + ? pick("[data-repeater-toggle]") + : (pick(`[data-repeater-move="${want.at}"]:not([disabled])`) ?? + pick(`[data-repeater-move]:not([disabled])`) ?? + pick("[data-repeater-toggle]")); + (target ?? pick("[data-repeater-toggle]"))?.focus(); + }); + + const commit = (next: FieldValues[], nextKeys: string[]) => { + keys.current = nextKeys; + props.onChange(next); + }; + const toggle = (key: string, on: boolean) => + setOpen((was) => { + const next = new Set(was); + if (on) next.add(key); + else next.delete(key); + return next; + }); + const add = () => { + const key = fresh(); + toggle(key, true); + focus.current = { key, at: "first" }; + commit([...items, {}], [...keys.current, key]); + }; + const move = (i: number, by: -1 | 1) => { + const j = i + by; + if (j < 0 || j >= items.length) return; + const next = [...items]; + const nextKeys = [...keys.current]; + [next[i], next[j]] = [next[j] as FieldValues, next[i] as FieldValues]; + [nextKeys[i], nextKeys[j]] = [nextKeys[j] as string, nextKeys[i] as string]; + focus.current = { key: nextKeys[j] as string, at: by < 0 ? "up" : "down" }; + say(labels.moved(i + 1, j + 1)); + commit(next, nextKeys); + }; + const duplicate = (i: number) => { + const key = fresh(); + toggle(key, true); + focus.current = { key, at: "first" }; + const at = i + 1; + commit( + [...items.slice(0, at), { ...(items[i] as FieldValues) }, ...items.slice(at)], + [...keys.current.slice(0, at), key, ...keys.current.slice(at)], + ); + }; + const remove = (i: number) => { + const removed = items[i] as FieldValues; + const key = keys.current[i] as string; + const legend = props.legend(i, removed); + const nextKeys = keys.current.filter((_, n) => n !== i); + const after = nextKeys[i]; + focus.current = after ? { key: after, at: "toggle" } : { at: "add" }; + say(labels.removed(i + 1)); + commit( + items.filter((_, n) => n !== i), + nextKeys, + ); + props.onRemoved?.({ + index: i, + legend, + undo: () => { + const now = latest.current; + const at = Math.min(i, now.length); + focus.current = { key, at: "toggle" }; + commit( + [...now.slice(0, at), removed, ...now.slice(at)], + [...keys.current.slice(0, at), key, ...keys.current.slice(at)], + ); + }, + }); + }; + + /** Opens an item and focuses its first control (the list of collapsed problems). */ + const reveal = (i: number) => { + const key = keys.current[i] as string; + toggle(key, true); + focus.current = { key, at: "first" }; + }; + return { root, keys, latest, open, said, toggle, add, move, duplicate, remove, reveal }; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index e25c8c64..c0e4f98d 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -252,13 +252,29 @@ export { type TransitionArrowProps, } from "./blocks/DirectionalIcon.tsx"; export { + type AnyFieldDefinition, EntryFieldsForm, type EntryFieldsFormLabels, type EntryFieldsFormProps, type EntryFieldsOriginal, type FieldImageSlot, + type FieldReferenceSlot, type FieldRichTextSlot, } from "./blocks/EntryFieldsForm.tsx"; +export { + EntryPicker, + type EntryPickerItem, + type EntryPickerLabels, + type EntryPickerPage, + type EntryPickerProps, +} from "./blocks/EntryPicker.tsx"; +export { + describeField, + FieldControl, + type FieldControlContext, + type FieldControlLabels, + type FieldControlProps, +} from "./blocks/FieldControl.tsx"; export { type FocalPoint, FocalPointPicker, @@ -357,6 +373,19 @@ export { type RecurrenceEditorLabels, type RecurrenceEditorProps, } from "./blocks/RecurrenceEditor.tsx"; +export { + ReferenceList, + type ReferenceListItem, + type ReferenceListLabels, + type ReferenceListProps, + type ReferenceTargetStatus, +} from "./blocks/ReferenceList.tsx"; +export { + RepeaterField, + type RepeaterFieldLabels, + type RepeaterFieldProps, + type RepeaterItemErrors, +} from "./blocks/RepeaterField.tsx"; export { type RescheduleApprovalEffect, RescheduleDialog, @@ -498,6 +527,12 @@ export { type TimezoneNoteProps, } from "./blocks/TimezoneNote.tsx"; export { ToggletipBadge, type ToggletipBadgeProps } from "./blocks/ToggletipBadge.tsx"; +export { + UsedByList, + type UsedByListLabels, + type UsedByListProps, + type UsedByRow, +} from "./blocks/UsedByList.tsx"; export { type ChartColors, mixHex, diff --git a/packages/ui/test/entry-fields-form.test.tsx b/packages/ui/test/entry-fields-form.test.tsx index 6022c2b6..d6396719 100644 --- a/packages/ui/test/entry-fields-form.test.tsx +++ b/packages/ui/test/entry-fields-form.test.tsx @@ -5,35 +5,14 @@ import { useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { EntryFieldsForm, - type EntryFieldsFormLabels, type EntryFieldsOriginal, + type FieldReferenceSlot, } from "../src/blocks/EntryFieldsForm.tsx"; import { fallbackText, readField, writeField } from "../src/blocks/entry-fields-model.ts"; import { wallClockOf, zonedToInstant } from "../src/blocks/zoned-time.ts"; +import { formLabels } from "./field-labels.ts"; -const labels: EntryFieldsFormLabels = { - neededToPublish: "Needed to publish", - fromOriginal: "From the original (English)", - openOriginal: "Open the original", - originalValue: (v) => `The original: ${v}`, - yes: "Yes", - no: "No", - noValue: "No value", - clear: "Clear", - originalImage: "The original's image", - date: { - date: "Date", - pickDate: "Pick a date", - chooseDate: "Choose a date", - invalidTime: "Enter a time such as 09:30", - calendar: { - previousMonth: "Previous month", - nextMonth: "Next month", - navigation: "Months", - day: (date) => date, - }, - }, -}; +const labels = formLabels; const DEFS: FieldDefinition[] = [ { @@ -209,7 +188,7 @@ describe("EntryFieldsForm", () => { ); }); - it("shows RFC-0045 reference and repeater values read-only until their blocks ship", () => { + it("shows a reference read-only without a host picker, and a repeater as its items", () => { const onChange = vi.fn(); const defs: FieldDefinition[] = [ { @@ -242,7 +221,8 @@ describe("EntryFieldsForm", () => { const host = screen.getByRole("textbox", { name: "Host" }); expect(host).toBeDisabled(); expect(host).toHaveValue('"entry_01HZX8K3Q9P1"'); - expect(screen.getByRole("textbox", { name: "Agenda" })).toHaveValue('[{"talk":"Hi"}]'); + expect(screen.getByRole("group", { name: "Item 1: Hi" })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Talk" })).toHaveValue("Hi"); expect(onChange).not.toHaveBeenCalled(); }); }); @@ -271,3 +251,165 @@ describe("entry fields model", () => { expect(wallClockOf(at, "Europe/Istanbul")).toEqual({ date: "2026-11-05", time: "19:30" }); }); }); + +const REF_DEFS: FieldDefinition[] = [ + { + key: "venue", + type: "reference", + labels: { en: { label: "Venue" } }, + localized: true, + collections: ["places"], + }, + { + key: "hosts", + type: "reference", + labels: { en: { label: "Hosts" } }, + localized: false, + collections: ["people"], + multiple: true, + maxItems: 3, + }, + { + key: "speakers", + type: "repeater", + labels: { en: { label: "Speakers" } }, + localized: false, + itemLabel: "person", + fields: [ + { + key: "person", + type: "reference", + labels: { en: { label: "Person" } }, + collections: ["people"], + }, + { key: "talk", type: "text", labels: { en: { label: "Talk" } }, requiredToPublish: true }, + ], + }, +]; +const titles: Record = { + entry_ada00001: "Ada Lovelace", + entry_hall0001: "Main hall", +}; +const slots: FieldReferenceSlot[] = []; +function Form(p: { + initial: Record; + onChange?: (v: Record) => void; + errors?: Record; + original?: EntryFieldsOriginal; + onItemRemoved?: (r: { field: string; legend: string; undo: () => void }) => void; +}) { + const [value, setValue] = useState(p.initial); + return ( + { + setValue(next); + p.onChange?.(next); + }} + locale="en" + defaultLocale="en" + errors={p.errors} + original={p.original} + multilingual + timeZone="UTC" + zoneLabel={(z) => z} + labels={labels} + referenceTitle={(id) => titles[id]} + onItemRemoved={p.onItemRemoved} + renderReference={(slot) => { + slots.push(slot); + return ( +
+ {`${slot.label}: ${slot.value.join(",")}`} + {!slot.readOnly && ( + + )} +
+ ); + }} + /> + ); +} +describe("EntryFieldsForm: references and repeaters (RFC-0045 §5)", () => { + afterEach(() => { + slots.length = 0; + }); + + it("gives the host one slot per reference, with its path, shape and limit", () => { + const onChange = vi.fn(); + render(
); + const by = (path: string) => slots.findLast((s) => s.path === path); + expect(by("venue")).toMatchObject({ value: [], multiple: false, max: 1 }); + expect(by("hosts")).toMatchObject({ value: ["entry_hall0001"], multiple: true, max: 3 }); + expect(by("speakers/0/person")).toMatchObject({ multiple: false, max: 1 }); + fireEvent.click(screen.getByRole("button", { name: "pick venue" })); + expect(onChange).toHaveBeenLastCalledWith({ + venue: "entry_ada00001", + hosts: ["entry_hall0001"], + speakers: [{}], + }); + fireEvent.click(screen.getByRole("button", { name: "pick hosts" })); + expect(onChange.mock.lastCall?.[0].hosts).toEqual(["entry_hall0001", "entry_ada00001"]); + fireEvent.click(screen.getByRole("button", { name: "pick speakers/0/person" })); + expect(onChange.mock.lastCall?.[0].speakers).toEqual([{ person: "entry_ada00001" }]); + // The legend names the item by its label sub-field, through the host's titles. + expect(screen.getByRole("group", { name: "Item 1: Ada Lovelace" })).toBeInTheDocument(); + }); + + it("puts each problem on its control by path", () => { + render( + , + ); + expect(within(field("venue")).getByText("Choose an entry from Places.")).toBeInTheDocument(); + expect(screen.getByRole("group", { name: "Venue" })).toHaveAccessibleDescription( + "Choose an entry from Places.", + ); + const second = screen.getByRole("group", { name: "Item 2" }); + expect(within(second).getByText("Needed to publish.")).toBeInTheDocument(); + const first = screen.getByRole("group", { name: "Item 1" }); + expect(within(first).getByText("An item needs a value.")).toBeInTheDocument(); + }); + + it("on a translation: a shared list is the original's, read-only, and says so", () => { + render( + , + ); + const speakers = field("speakers"); + expect(within(speakers).getByText(/Not translated: this list is shared/)).toBeInTheDocument(); + expect(within(speakers).getByRole("link", { name: "Open the original" })).toBeInTheDocument(); + expect(within(speakers).getByRole("textbox", { name: /Talk/ })).toBeDisabled(); + expect(within(speakers).queryByRole("button", { name: "Add item" })).toBeNull(); + // An empty localized reference words the original's targets. + expect(within(field("venue")).getByText("The original: Main hall")).toBeInTheDocument(); + expect(slots.findLast((s) => s.path === "venue")?.fallback).toEqual(["entry_hall0001"]); + }); + + it("hands a removed item's undo to the host", async () => { + const onItemRemoved = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Actions for item 1" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "Remove" })); + expect(onItemRemoved).toHaveBeenCalledWith( + expect.objectContaining({ field: "speakers", legend: "Item 1" }), + ); + }); +}); diff --git a/packages/ui/test/entry-picker.test.tsx b/packages/ui/test/entry-picker.test.tsx new file mode 100644 index 00000000..3f35023a --- /dev/null +++ b/packages/ui/test/entry-picker.test.tsx @@ -0,0 +1,193 @@ +/** RFC-0045 §5: the entry picker, the chosen targets and "Used by". */ +import { Button } from "@cloudflare/kumo"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EntryPicker, type EntryPickerItem } from "../src/blocks/EntryPicker.tsx"; +import { ReferenceList } from "../src/blocks/ReferenceList.tsx"; +import { UsedByList } from "../src/blocks/UsedByList.tsx"; +import { pickerLabels, referenceLabels } from "./field-labels.ts"; + +const HALL: EntryPickerItem = { + id: "entry_hall001", + title: "Main hall", + collection: "Places", + status: "published", + locale: "English", +}; +const ROOM: EntryPickerItem = { ...HALL, id: "entry_room001", title: "Room B", status: "draft" }; +const ATTIC: EntryPickerItem = { ...HALL, id: "entry_attic01", title: "Attic" }; + +function open(props: Partial[0]> = {}) { + const search = vi.fn(async (_q: string, _cursor: string | undefined) => ({ + items: [HALL, ROOM], + })); + const onPick = vi.fn(); + render( + Choose venue} + search={search} + multiple={false} + remaining={1} + onPick={onPick} + labels={pickerLabels} + {...props} + />, + ); + fireEvent.click(screen.getByRole("button", { name: "Choose venue" })); + return { search: (props.search as typeof search | undefined) ?? search, onPick }; +} + +const box = () => screen.getByRole("combobox", { name: "Search entries" }); + +afterEach(cleanup); + +describe("EntryPicker", () => { + it("searches through its prop and announces the result count politely", async () => { + const { search } = open(); + expect(await screen.findByRole("option", { name: /Main hall/ })).toBeInTheDocument(); + expect(search).toHaveBeenCalledWith("", undefined); + const status = screen.getByRole("status"); + expect(status).toHaveAttribute("aria-live", "polite"); + expect(status).toHaveTextContent("2 entries found"); + const option = screen.getByRole("option", { name: /Room B/ }); + expect(within(option).getByText("Places")).toBeInTheDocument(); + expect(within(option).getByText("Draft")).toBeInTheDocument(); + fireEvent.change(box(), { target: { value: "hall" } }); + await waitFor(() => expect(search).toHaveBeenLastCalledWith("hall", undefined)); + }); + + it("moves with the arrow keys, picks with Enter, closes and returns focus to the trigger", async () => { + const { onPick } = open(); + await screen.findByRole("option", { name: /Room B/ }); + expect(box()).toHaveFocus(); + fireEvent.keyDown(box(), { key: "ArrowDown" }); + const room = screen.getByRole("option", { name: /Room B/ }); + expect(box()).toHaveAttribute("aria-activedescendant", room.id); + fireEvent.keyDown(box(), { key: "Enter" }); + expect(onPick).toHaveBeenCalledWith([ROOM]); + await waitFor(() => expect(screen.queryByRole("combobox")).toBeNull()); + await waitFor(() => expect(screen.getByRole("button", { name: "Choose venue" })).toHaveFocus()); + }); + + it("several targets: ticks up to the remaining count, then adds them together", async () => { + const search = vi.fn(async () => ({ items: [HALL, ROOM, ATTIC] })); + const { onPick } = open({ + multiple: true, + remaining: 2, + search, + unavailable: new Set([ATTIC.id]), + }); + await screen.findByRole("option", { name: /Attic/ }); + expect(screen.getByRole("listbox")).toHaveAttribute("aria-multiselectable", "true"); + expect(screen.getByRole("option", { name: /Attic/ })).toHaveAttribute("aria-disabled", "true"); + fireEvent.keyDown(box(), { key: "Enter" }); + fireEvent.click(screen.getByRole("option", { name: /Room B/ })); + expect(screen.getByRole("option", { name: /Main hall/ })).toHaveAttribute( + "aria-selected", + "true", + ); + expect(screen.getByText("You can choose 0 more")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Add 2" })); + expect(onPick).toHaveBeenCalledWith([HALL, ROOM]); + }); + + it("keeps searching after a truncated read, from the cursor", async () => { + const search = vi.fn(async (_q: string, cursor: string | undefined) => + cursor ? { items: [ATTIC] } : { items: [], cursor: "places:en:m", truncated: true }, + ); + open({ search }); + fireEvent.click(await screen.findByRole("button", { name: "Keep searching" })); + expect(await screen.findByRole("option", { name: /Attic/ })).toBeInTheDocument(); + expect(search).toHaveBeenLastCalledWith("", "places:en:m"); + }); + + it("says when nothing matches", async () => { + open({ search: async () => ({ items: [] }) }); + await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("No entries found")); + // The status line and the empty state. + expect(screen.getAllByText("No entries found")).toHaveLength(2); + }); +}); + +describe("ReferenceList", () => { + it("shows each target's state and moves and removes through buttons and the menu", async () => { + const onMove = vi.fn(); + const onRemove = vi.fn(); + render( + , + ); + const list = screen.getByRole("list", { name: "Speakers" }); + expect(within(list).getByRole("link", { name: "Ada" })).toHaveAttribute("href", "/a"); + expect(within(list).getByText("Draft")).toBeInTheDocument(); + expect(within(list).getAllByText("Will not show until published")).toHaveLength(1); + expect(within(list).getByText("Not found")).toBeInTheDocument(); + expect(within(list).getByText("entry_gone001")).toBeInTheDocument(); + expect(within(list).getByRole("status", { name: "Loading" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Move Ada up" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Move Grace up" })); + expect(onMove).toHaveBeenCalledWith(1, -1); + fireEvent.click(screen.getByRole("button", { name: "Actions for Grace" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "Remove" })); + expect(onRemove).toHaveBeenCalledWith(1); + }); + + it("read-only shows no actions", () => { + render( + , + ); + expect(screen.getByText("Archived")).toBeInTheDocument(); + expect(screen.queryByRole("button")).toBeNull(); + }); +}); + +describe("UsedByList", () => { + it("lists referrers with where the value sits and their status", () => { + render( + , + ); + const table = screen.getByRole("table", { name: "Entries that reference this one" }); + expect(within(table).getByRole("link", { name: "Launch" })).toHaveAttribute("href", "/e/1"); + expect(within(table).getByText("Events · English · Venue")).toBeInTheDocument(); + expect(within(table).getByText("Also shown in Turkish")).toBeInTheDocument(); + expect(within(table).getByText("Published")).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/test/field-labels.ts b/packages/ui/test/field-labels.ts new file mode 100644 index 00000000..ef007c6e --- /dev/null +++ b/packages/ui/test/field-labels.ts @@ -0,0 +1,84 @@ +/** English test words for the field blocks (blocks take every word as a prop). */ +import type { EntryFieldsFormLabels } from "../src/blocks/EntryFieldsForm.tsx"; +import type { EntryPickerLabels } from "../src/blocks/EntryPicker.tsx"; +import type { ReferenceListLabels } from "../src/blocks/ReferenceList.tsx"; +import type { RepeaterFieldLabels } from "../src/blocks/RepeaterField.tsx"; + +export const repeaterLabels: RepeaterFieldLabels = { + add: "Add item", + count: (n, max) => `${n} of ${max}`, + empty: "No items yet", + moveUp: (i) => `Move item ${i} up`, + moveDown: (i) => `Move item ${i} down`, + actions: (i) => `Actions for item ${i}`, + menuMoveUp: "Move up", + menuMoveDown: "Move down", + duplicate: "Duplicate", + remove: "Remove", + moved: (from, to) => `Item ${from} moved to position ${to}`, + removed: (i) => `Item ${i} removed`, + invalidItems: (n) => `${n} items need attention`, +}; + +export const formLabels: EntryFieldsFormLabels = { + neededToPublish: "Needed to publish", + fromOriginal: "From the original (English)", + openOriginal: "Open the original", + originalValue: (v) => `The original: ${v}`, + yes: "Yes", + no: "No", + noValue: "No value", + clear: "Clear", + originalImage: "The original's image", + sharedList: "Not translated: this list is shared by every language.", + items: (n) => `${n} items`, + repeater: repeaterLabels, + itemLegend: (i, name) => (name ? `Item ${i}: ${name}` : `Item ${i}`), + date: { + date: "Date", + pickDate: "Pick a date", + chooseDate: "Choose a date", + invalidTime: "Enter a time such as 09:30", + calendar: { + previousMonth: "Previous month", + nextMonth: "Next month", + navigation: "Months", + day: (date) => date, + }, + }, +}; + +export const referenceLabels: ReferenceListLabels = { + draft: "Draft", + review: "In review", + archived: "Archived", + notFound: "Not found", + notShown: "Will not show until published", + notFoundHint: "This entry was deleted.", + moveUp: (t) => `Move ${t} up`, + moveDown: (t) => `Move ${t} down`, + actions: (t) => `Actions for ${t}`, + open: "Open", + menuMoveUp: "Move up", + menuMoveDown: "Move down", + remove: "Remove", + loading: "Loading", +}; + +export const pickerLabels: EntryPickerLabels = { + title: "Choose entries", + search: "Search entries", + results: (n) => (n === 0 ? "No entries found" : `${n} entries found`), + searching: "Searching…", + noResults: "No entries found", + failed: "The search failed", + more: "Show more", + keepSearching: "Keep searching", + truncated: "Not every entry was searched yet.", + remaining: (n) => `You can choose ${n} more`, + unavailable: "Already chosen", + status: { draft: "Draft", review: "In review", published: "Published", archived: "Archived" }, + add: (n) => `Add ${n}`, + cancel: "Cancel", + close: "Close", +}; diff --git a/packages/ui/test/repeater-field.test.tsx b/packages/ui/test/repeater-field.test.tsx new file mode 100644 index 00000000..cb9a4aa4 --- /dev/null +++ b/packages/ui/test/repeater-field.test.tsx @@ -0,0 +1,163 @@ +/** RFC-0045 §5: the repeater block — items as fieldsets, add, moves, remove with undo, focus, errors. */ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { FieldValues } from "../src/blocks/entry-fields-model.ts"; +import { RepeaterField, type RepeaterItemErrors } from "../src/blocks/RepeaterField.tsx"; +import { repeaterLabels } from "./field-labels.ts"; + +const SUBS = [{ key: "name" }, { key: "role" }]; + +function Harness({ + initial, + max = 5, + onChange = () => {}, + onRemoved, + itemErrors, +}: { + initial: FieldValues[]; + max?: number; + onChange?: (items: FieldValues[]) => void; + onRemoved?: (r: { index: number; legend: string; undo: () => void }) => void; + itemErrors?: ReadonlyMap; +}) { + const [items, setItems] = useState(initial); + return ( + { + setItems(next); + onChange(next); + }} + subFields={SUBS} + max={max} + legend={(i, item) => `Speaker ${i + 1}${item.name ? `: ${String(item.name)}` : ""}`} + itemErrors={itemErrors} + onRemoved={onRemoved} + labels={repeaterLabels} + renderControl={({ sub, index, value, onChange: set, error }) => ( + + )} + /> + ); +} + +const items = () => + screen.getAllByRole("group").filter((g) => g.hasAttribute("data-repeater-item")); +const legends = () => items().map((g) => g.querySelector("legend")?.textContent); + +async function menuItem(index: number, name: string) { + fireEvent.click(screen.getByRole("button", { name: `Actions for item ${index}` })); + return screen.findByRole("menuitem", { name }); +} + +afterEach(cleanup); + +describe("RepeaterField", () => { + it("shows each item as a fieldset named by its legend, and the count", () => { + render(); + expect(screen.getByRole("group", { name: "Speaker 1: Ada" })).toBeInTheDocument(); + expect(screen.getByRole("group", { name: "Speaker 2: Grace" })).toBeInTheDocument(); + expect(screen.getByText("2 of 2")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Add item" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Move item 1 up" })).toBeDisabled(); + }); + + it("adds an item and moves focus to its first control", async () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Add item" })); + expect(onChange).toHaveBeenLastCalledWith([{ name: "Ada" }, {}]); + await waitFor(() => expect(screen.getByLabelText("name 2")).toHaveFocus()); + fireEvent.change(screen.getByLabelText("name 2"), { target: { value: "Grace" } }); + expect(onChange).toHaveBeenLastCalledWith([{ name: "Ada" }, { name: "Grace" }]); + }); + + it("moves items with buttons, announces it, and keeps focus on the moved item", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Move item 3 up" })); + expect(legends()).toEqual(["Speaker 1: Ada", "Speaker 2: Linus", "Speaker 3: Grace"]); + expect(screen.getByRole("status")).toHaveTextContent("Item 3 moved to position 2"); + await waitFor(() => + expect(screen.getByRole("button", { name: "Move item 2 up" })).toHaveFocus(), + ); + // At the top the up button is disabled: focus falls back to the item's other move button. + fireEvent.click(screen.getByRole("button", { name: "Move item 2 up" })); + await waitFor(() => + expect(screen.getByRole("button", { name: "Move item 1 down" })).toHaveFocus(), + ); + expect(legends()[0]).toBe("Speaker 1: Linus"); + }); + + it("removes an item, focuses the next one, and puts it back on undo", async () => { + const onRemoved = vi.fn(); + render( + , + ); + fireEvent.click(await menuItem(2, "Remove")); + expect(legends()).toEqual(["Speaker 1: Ada", "Speaker 2: Linus"]); + await waitFor(() => + expect( + within(items()[1] as HTMLElement).getByRole("button", { expanded: true }), + ).toHaveFocus(), + ); + expect(onRemoved).toHaveBeenCalledWith( + expect.objectContaining({ index: 1, legend: "Speaker 2: Grace" }), + ); + act(() => onRemoved.mock.lastCall?.[0].undo()); + expect(legends()).toEqual(["Speaker 1: Ada", "Speaker 2: Grace", "Speaker 3: Linus"]); + await waitFor(() => + expect( + within(items()[1] as HTMLElement).getByRole("button", { expanded: true }), + ).toHaveFocus(), + ); + }); + + it("after removing the last item, focus goes to Add item", async () => { + render(); + fireEvent.click(await menuItem(1, "Remove")); + expect(screen.getByText("No items yet")).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole("button", { name: "Add item" })).toHaveFocus()); + }); + + it("duplicates an item after itself", async () => { + const onChange = vi.fn(); + render(); + fireEvent.click(await menuItem(1, "Duplicate")); + expect(onChange).toHaveBeenLastCalledWith([ + { name: "Ada", role: "Host" }, + { name: "Ada", role: "Host" }, + ]); + }); + + it("opens items with problems, shows them on their control, and lists collapsed ones", async () => { + const many = [1, 2, 3, 4].map((n) => ({ name: `P${n}` })); + const { rerender } = render(); + // More than three items start folded. + expect(screen.queryByLabelText("name 3")).toBeNull(); + const errors = new Map([[2, { subs: new Map([["role", "Needed to publish"]]) }]]); + rerender(); + await waitFor(() => expect(screen.getByLabelText("role 3")).toBeInTheDocument()); + expect(within(items()[2] as HTMLElement).getByText("Needed to publish")).toBeInTheDocument(); + // Folding it again lists it above the items, with a way back to it. + fireEvent.click(within(items()[2] as HTMLElement).getByRole("button", { expanded: true })); + const listed = await screen.findByText("1 items need attention"); + const back = within(listed.parentElement as HTMLElement).getByRole("button", { + name: "Speaker 3: P3", + }); + fireEvent.click(back); + await waitFor(() => expect(screen.getByLabelText("name 3")).toHaveFocus()); + }); +}); diff --git a/scripts/size-baseline.json b/scripts/size-baseline.json index 6bb37d93..4663c741 100644 --- a/scripts/size-baseline.json +++ b/scripts/size-baseline.json @@ -20,7 +20,7 @@ "packages/core-ts/src/assets.ts": 1370, "packages/core-ts/src/automations.ts": 1003, "packages/core-ts/src/menu.ts": 928, - "packages/core-ts/src/releases.ts": 902, + "packages/core-ts/src/releases.ts": 882, "packages/core-ts/src/store.ts": 1212, "packages/core-ts/src/surface-interop.ts": 1192, "packages/core-ts/src/taxonomy.ts": 1718, @@ -180,7 +180,7 @@ "lines": 191 }, "apps/studio/src/routes/ArticleEditorPage.tsx::ArticleEditorPage": { - "lines": 446 + "lines": 442 }, "apps/studio/src/routes/ArticlesPage.tsx::ArticlesPage": { "lines": 192 @@ -369,7 +369,7 @@ "params": 5 }, "packages/cloudflare/src/admin-worker.ts::createAdminWorker": { - "lines": 262 + "lines": 261 }, "packages/cloudflare/src/admin-worker.ts::createAdminWorker>fetch": { "lines": 199 @@ -414,7 +414,7 @@ "params": 5 }, "packages/core-ts/src/cache-purge.ts::planCachePurge": { - "lines": 207 + "lines": 184 }, "packages/core-ts/src/media/image.ts::inspectAvif": { "depth": 6 From 5311a1b9328018df458e8a66ccf57b6c0dc29276 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:30:04 +0300 Subject: [PATCH 2/4] fix(studio,ui): RFC-0045 step 6a review round 1 (PR #219) - Repeater undo writes through the latest onChange and items; EntryFieldsForm writes over its latest value and the editor updates its draft functionally, so an undo keeps later edits and the saved rev. The toast is scoped to its entry (closed on leave/unmount); undo returns false when the list is gone or full, and Studio says so. - Item errors are re-keyed to stable item keys when they arrive, so they follow their items through moves, removals and undo. - Live regions empty before each message (repeated messages re-announce). - ReferenceList keeps focus on a moved row, moves it to the next row or the picker trigger after a remove, and announces both. - EntryPicker: a full field keeps its trigger focusable (aria-disabled); a failed search is the polite status; the spinner is hidden from AT. - Groups read sends only well-formed group ids; others show "Not found"; a failed read shows an error banner with retry. - During running field operations: renamed keys (top level and sub-field) are read and written through the marker; unconverted values show read-only as stored with "Being changed". - Saves leave out empty repeater items. - i18n: six new fields keys, AI drafts for 18 languages. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- apps/studio/src/components/Toast.ts | 2 + .../src/components/fields/EntryFieldsCard.tsx | 18 +- .../src/components/fields/FieldReference.tsx | 68 ++-- .../src/components/fields/useGroupCards.ts | 23 +- .../src/components/fields/useItemUndo.ts | 47 +++ apps/studio/src/i18n/generated/params.ts | 2 + apps/studio/src/i18n/lazy/fields.ar.ts | 9 +- apps/studio/src/i18n/lazy/fields.de.ts | 10 +- apps/studio/src/i18n/lazy/fields.en.ts | 7 + apps/studio/src/i18n/lazy/fields.es.ts | 10 +- apps/studio/src/i18n/lazy/fields.fa.ts | 9 +- apps/studio/src/i18n/lazy/fields.fr.ts | 10 +- apps/studio/src/i18n/lazy/fields.he.ts | 8 +- apps/studio/src/i18n/lazy/fields.hi.ts | 9 +- apps/studio/src/i18n/lazy/fields.id.ts | 9 +- apps/studio/src/i18n/lazy/fields.it.ts | 10 +- apps/studio/src/i18n/lazy/fields.ja.ts | 9 +- apps/studio/src/i18n/lazy/fields.ko.ts | 9 +- apps/studio/src/i18n/lazy/fields.nl.ts | 10 +- apps/studio/src/i18n/lazy/fields.pl.ts | 9 +- apps/studio/src/i18n/lazy/fields.pt-BR.ts | 9 +- apps/studio/src/i18n/lazy/fields.ru.ts | 9 +- apps/studio/src/i18n/lazy/fields.tr.ts | 9 +- apps/studio/src/i18n/lazy/fields.uk.ts | 9 +- apps/studio/src/i18n/lazy/fields.zh-Hans.ts | 8 +- apps/studio/src/lib/empty-items.ts | 30 ++ apps/studio/src/lib/field-references.ts | 23 +- apps/studio/src/routes/ArticleEditorPage.tsx | 12 +- apps/studio/test/fields-references.test.tsx | 125 +++++++ docs/design/DESIGN.md | 2 +- ...ctions-stage-4-references-and-repeaters.md | 10 + packages/ui/src/blocks/EntryFieldsForm.tsx | 30 +- packages/ui/src/blocks/EntryPicker.tsx | 59 +++- packages/ui/src/blocks/EntryRepeater.tsx | 41 ++- packages/ui/src/blocks/FieldControl.tsx | 6 +- packages/ui/src/blocks/ReferenceList.tsx | 328 +++++++++++------- packages/ui/src/blocks/RepeaterField.tsx | 36 +- packages/ui/src/blocks/announcer.ts | 19 + packages/ui/src/blocks/entry-fields-model.ts | 55 +++ packages/ui/src/blocks/repeater-state.ts | 74 +++- packages/ui/test/entry-fields-form.test.tsx | 74 ++++ packages/ui/test/entry-picker.test.tsx | 90 +++++ packages/ui/test/field-labels.ts | 3 + packages/ui/test/repeater-field.test.tsx | 71 +++- 44 files changed, 1139 insertions(+), 281 deletions(-) create mode 100644 apps/studio/src/components/fields/useItemUndo.ts create mode 100644 apps/studio/src/lib/empty-items.ts create mode 100644 packages/ui/src/blocks/announcer.ts diff --git a/apps/studio/src/components/Toast.ts b/apps/studio/src/components/Toast.ts index 3c994fff..1a2f9f64 100644 --- a/apps/studio/src/components/Toast.ts +++ b/apps/studio/src/components/Toast.ts @@ -38,6 +38,8 @@ export function useToast() { } as never) as unknown as string; return id; }, + /** Closes a toast by its id (a notice that no longer applies). */ + close: (id: string) => manager.close(id), error: (title: string, description?: string) => manager.add({ title, description, type: "error" } as never), /** diff --git a/apps/studio/src/components/fields/EntryFieldsCard.tsx b/apps/studio/src/components/fields/EntryFieldsCard.tsx index d19d727f..7e3807a6 100644 --- a/apps/studio/src/components/fields/EntryFieldsCard.tsx +++ b/apps/studio/src/components/fields/EntryFieldsCard.tsx @@ -19,10 +19,10 @@ import { languageName } from "../../lib/languages.ts"; import { zoneLabel } from "../../lib/schedules.ts"; import { primaryHostname } from "../../lib/site-settings.ts"; import { BodyEditor } from "../BodyEditor.tsx"; -import { useToast } from "../Toast.ts"; import { FieldImage } from "./FieldImage.tsx"; import { FieldReference } from "./FieldReference.tsx"; import { useGroupCards } from "./useGroupCards.ts"; +import { useItemUndo } from "./useItemUndo.ts"; type FT = TFunction<"fields">; @@ -94,6 +94,7 @@ function useFormLabels(t: FT, originalLocale: string | undefined, locale: string clear: t("fields.editor.clear"), originalImage: t("fields.editor.originalImage"), sharedList: t("fields.editor.sharedList"), + beingChanged: t("fields.editor.beingChanged"), items: (count) => t("fields.editor.originalItems", { count }), itemLegend: (index, name) => t("fields.editor.itemLegend", { index, named: name ? "yes" : "no", name: name ?? "" }), @@ -151,7 +152,7 @@ export function EntryFieldsCard({ disabled: boolean; }) { const { t, locale } = useI18n("fields", "bodyEditor", "media"); - const toast = useToast(); + const onItemRemoved = useItemUndo(entry.id); const definitions = site.collections?.[entry.collection]?.fields ?? []; const original = useOriginal(siteId, entry); const labels = useFormLabels(t, original.data?.locale, locale); @@ -210,19 +211,10 @@ export function EntryFieldsCard({ entryLocale={entry.locale} ownGroup={memberGroup(entry)} slot={slot} - cards={groups.cards} - onPicked={groups.add} + groups={groups} /> )} - onItemRemoved={({ field, legend, undo }) => - toast.notice({ - id: `field-item-removed-${field}`, - title: t("fields.repeater.removedToast", { item: legend }), - tone: "info", - timeout: 10_000, - actions: [{ label: t("fields.repeater.undo"), onClick: undo }], - }) - } + onItemRemoved={onItemRemoved} renderRichText={(slot) => (
diff --git a/apps/studio/src/components/fields/FieldReference.tsx b/apps/studio/src/components/fields/FieldReference.tsx index 0ce939d9..166db0e5 100644 --- a/apps/studio/src/components/fields/FieldReference.tsx +++ b/apps/studio/src/components/fields/FieldReference.tsx @@ -1,5 +1,6 @@ import type { Site } from "@cloudhal/core-contracts"; import { + Banner, Button, EntryPicker, type EntryPickerItem, @@ -10,7 +11,7 @@ import { type ReferenceListLabels, Text, } from "@cloudhal/ui"; -import { PencilSimple, Plus } from "@phosphor-icons/react"; +import { ArrowClockwise, PencilSimple, Plus } from "@phosphor-icons/react"; import { useMemo, useRef } from "react"; import { formatList, type TFunction, useI18n } from "../../i18n/index.tsx"; import { type ReferenceCard, referencesApi } from "../../lib/api/references.ts"; @@ -18,11 +19,12 @@ import { collectionName } from "../../lib/collections.ts"; import { entryHref } from "../../lib/entry-href.ts"; import { chooseMember } from "../../lib/field-references.ts"; import { languageName } from "../../lib/languages.ts"; +import type { useGroupCards } from "./useGroupCards.ts"; type FT = TFunction<"fields">; -/** The targets' members the form knows (any status); `[]` for a group with none left. */ -export type GroupCards = ReadonlyMap; +/** The targets' members the form knows (`useGroupCards`). */ +export type GroupCards = ReturnType; function useListLabels(t: FT): ReferenceListLabels { return useMemo( @@ -41,6 +43,8 @@ function useListLabels(t: FT): ReferenceListLabels { menuMoveDown: t("fields.reference.menuMoveDown"), remove: t("fields.reference.remove"), loading: t("common.loading"), + moved: (title, position) => t("fields.reference.moved", { title, position }), + removed: (title) => t("fields.reference.removed", { title }), }), [t], ); @@ -87,17 +91,15 @@ export function FieldReference({ entryLocale, ownGroup, slot, - cards, - onPicked, + groups, }: { siteId: string; site: Site; entryLocale: string; ownGroup: string; slot: FieldReferenceSlot; - cards: GroupCards; - /** Cards the picker returned, so a new target shows at once. */ - onPicked: (cards: ReferenceCard[]) => void; + /** The targets' members, and where the picker's cards go so a new target shows at once. */ + groups: GroupCards; }) { const { t, locale } = useI18n("fields"); const { def, value, multiple, max } = slot; @@ -111,7 +113,7 @@ export function FieldReference({ collections: def.collections, }; const items: ReferenceListItem[] = value.map((id) => { - const members = cards.get(id); + const members = groups.cards.get(id); if (members === undefined) return { id }; const member = chooseMember(members, options); if (!member) return { id, status: "missing" }; @@ -135,26 +137,41 @@ export function FieldReference({ status: card.status, locale: languageName(locale, card.locale), }); + const trigger = useRef(null); // The picker's cards by group, so a new target shows before the groups' query answers. const found = useRef(new Map()).current; return (
- {items.length > 0 && ( - 1} - readOnly={slot.readOnly} - onMove={(i, by) => { - const next = [...value]; - const j = i + by; - [next[i], next[j]] = [next[j] as string, next[i] as string]; - set(next); - }} - onRemove={(i) => set(value.filter((_, n) => n !== i))} - labels={listLabels} + {groups.failed && ( + } + loading={groups.retrying} + onClick={groups.retry} + > + {t("fields.reference.retry")} + + } /> )} + 1} + readOnly={slot.readOnly} + onMove={(i, by) => { + const next = [...value]; + const j = i + by; + [next[i], next[j]] = [next[j] as string, next[i] as string]; + set(next); + }} + onRemove={(i) => set(value.filter((_, n) => n !== i))} + focusAfterLast={() => trigger.current} + labels={listLabels} + /> {!slot.readOnly && ( : } - disabled={remaining <= 0} + ref={trigger} > {multiple ? t("fields.reference.add") @@ -173,6 +190,7 @@ export function FieldReference({ } multiple={multiple} + disabled={remaining <= 0} remaining={Math.max(remaining, 0)} unavailable={new Set([...value, ownGroup])} search={async (q, cursor) => { @@ -190,7 +208,7 @@ export function FieldReference({ }} onPick={(picked) => { const ids = picked.map((p) => p.id); - onPicked(ids.flatMap((id) => found.get(id) ?? [])); + groups.add(ids.flatMap((id) => found.get(id) ?? [])); set(multiple ? [...value, ...ids] : ids.slice(0, 1)); }} labels={pickerLabels} diff --git a/apps/studio/src/components/fields/useGroupCards.ts b/apps/studio/src/components/fields/useGroupCards.ts index 78cd4b93..7274f695 100644 --- a/apps/studio/src/components/fields/useGroupCards.ts +++ b/apps/studio/src/components/fields/useGroupCards.ts @@ -1,16 +1,22 @@ -import { FIELD_LIMITS } from "@cloudhal/core-ts/fields"; +import { FIELD_LIMITS, GROUP_ID_PATTERN } from "@cloudhal/core-ts/fields"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { useCallback, useMemo, useState } from "react"; import { type ReferenceCard, referencesApi } from "../../lib/api/references.ts"; +const GROUP_ID = new RegExp(GROUP_ID_PATTERN); + /** * The members of every group the form names (RFC-0045 §5), any status, from `GET * …/entries/groups` (100 ids a request), and the picker's cards for targets chosen since, so a new - * target shows before the answer. A group with no member left maps to `[]` ("Not found"). + * target shows before the answer. A group with no member left maps to `[]` ("Not found"), and so + * does a value that is no group id (text a running conversion has not rewritten yet): it is never + * sent, so one such value can't fail the read for the others. `failed` and `retry` let the host + * say when the read itself failed. */ export function useGroupCards(siteId: string, ids: readonly string[]) { const [picked, setPicked] = useState>(new Map()); - const wanted = useMemo(() => [...new Set(ids)].sort(), [ids]); + const unique = useMemo(() => [...new Set(ids)].sort(), [ids]); + const wanted = useMemo(() => unique.filter((id) => GROUP_ID.test(id)), [unique]); const query = useQuery({ queryKey: ["entry-groups", siteId, wanted.join(",")], queryFn: async () => { @@ -29,9 +35,10 @@ export function useGroupCards(siteId: string, ids: readonly string[]) { }); const cards = useMemo(() => { const out = new Map(picked); + for (const id of unique) if (!GROUP_ID.test(id)) out.set(id, []); for (const [id, members] of Object.entries(query.data ?? {})) out.set(id, members); return out; - }, [picked, query.data]); + }, [picked, query.data, unique]); const add = useCallback( (more: readonly ReferenceCard[]) => setPicked((was) => { @@ -41,5 +48,11 @@ export function useGroupCards(siteId: string, ids: readonly string[]) { }), [], ); - return { cards, add }; + return { + cards, + add, + failed: query.isError, + retrying: query.isFetching, + retry: () => void query.refetch(), + }; } diff --git a/apps/studio/src/components/fields/useItemUndo.ts b/apps/studio/src/components/fields/useItemUndo.ts new file mode 100644 index 00000000..5591a0e9 --- /dev/null +++ b/apps/studio/src/components/fields/useItemUndo.ts @@ -0,0 +1,47 @@ +import { useEffect, useRef } from "react"; +import { useI18n } from "../../i18n/index.tsx"; +import { useToast } from "../Toast.ts"; + +const UNDO_MS = 10_000; + +/** + * The undo toast after a repeater item is removed (RFC-0045 §5), scoped to one entry: its toasts + * close when the editor leaves the entry or unmounts, and an undo for another entry does nothing. + * When the list can't take the item back (it is full again), the person is told. + */ +export function useItemUndo(entryId: string) { + const { t } = useI18n("fields"); + const toast = useToast(); + const latest = useRef({ entryId, toast }); + latest.current = { entryId, toast }; + const open = useRef(new Set()); + const count = useRef(0); + // Closes this entry's toasts when the editor leaves it or unmounts. + useEffect( + () => () => { + for (const id of open.current) latest.current.toast.close(id); + open.current.clear(); + }, + [entryId], + ); + return ({ field, legend, undo }: { field: string; legend: string; undo: () => boolean }) => { + const id = `field-item-removed-${entryId}-${field}-${count.current++}`; + open.current.add(id); + toast.notice({ + id, + title: t("fields.repeater.removedToast", { item: legend }), + tone: "info", + timeout: UNDO_MS, + actions: [ + { + label: t("fields.repeater.undo"), + onClick: () => { + open.current.delete(id); + if (latest.current.entryId !== entryId) return; + if (!undo()) latest.current.toast.error(t("fields.repeater.undoFailed")); + }, + }, + ], + }); + }; +} diff --git a/apps/studio/src/i18n/generated/params.ts b/apps/studio/src/i18n/generated/params.ts index 41e7863b..e92a355c 100644 --- a/apps/studio/src/i18n/generated/params.ts +++ b/apps/studio/src/i18n/generated/params.ts @@ -292,11 +292,13 @@ export interface MessageParams { "fields.reference.meta": { collection: string | number; language: string | number }; "fields.reference.moveDown": { title: string | number }; "fields.reference.moveUp": { title: string | number }; + "fields.reference.moved": { title: string | number; position: number }; "fields.reference.picker.add": { count: number }; "fields.reference.picker.description": { collections: string | number }; "fields.reference.picker.remaining": { count: number }; "fields.reference.picker.results": { count: number }; "fields.reference.picker.title": { multiple: "yes" | "no" }; + "fields.reference.removed": { title: string | number }; "fields.repeater.actions": { index: number }; "fields.repeater.count": { count: number; max: number }; "fields.repeater.invalidItems": { count: number }; diff --git a/apps/studio/src/i18n/lazy/fields.ar.ts b/apps/studio/src/i18n/lazy/fields.ar.ts index a1105e7f..b01f3519 100644 --- a/apps/studio/src/i18n/lazy/fields.ar.ts +++ b/apps/studio/src/i18n/lazy/fields.ar.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ar `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: ca7b0b36086b7202 +// hash: 0e2f4d725aa77b38 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -28,6 +28,8 @@ export const messages: NamespaceMessages<"fields"> = { clear: "امسح", originalImage: "صورة الأصل", sharedList: "غير مترجمة: هذه القائمة مشتركة بين كل اللغات.", + beingChanged: + "قيد التغيير: يحوّل تغيير حقل قيد التنفيذ هذه القيمة. يمكنك تعديلها عند انتهاء التغيير.", originalItems: "{count, plural, zero {لا عناصر} one {عنصر واحد} two {عنصران} few {# عناصر} many {# عنصرًا} other {# عنصر}}", itemLegend: @@ -79,6 +81,7 @@ export const messages: NamespaceMessages<"fields"> = { "{count, plural, zero {لا عناصر مطوية تحتاج إلى انتباه:} one {عنصر مطوي واحد يحتاج إلى انتباه:} two {عنصران مطويان يحتاجان إلى انتباه:} few {# عناصر مطوية تحتاج إلى انتباه:} many {# عنصرًا مطويًا يحتاج إلى انتباه:} other {# عنصر مطوي يحتاج إلى انتباه:}}", removedToast: "أُزيل {item}", undo: "تراجع", + undoFailed: "تعذّرت إعادة العنصر: القائمة ممتلئة. أزل عنصرًا أولًا.", }, reference: { choose: "اختر إدخالًا", @@ -96,6 +99,10 @@ export const messages: NamespaceMessages<"fields"> = { menuMoveUp: "انقل لأعلى", menuMoveDown: "انقل لأسفل", remove: "أزل", + moved: "نُقل {title} إلى الموضع {position, number}", + removed: "أُزيل {title}", + loadFailed: "تعذّر تحميل الإدخالات المختارة.", + retry: "أعد المحاولة", picker: { title: "{multiple, select, yes {اختر إدخالات} no {اختر إدخالًا} other {اختر إدخالًا}}", description: "من {collections}.", diff --git a/apps/studio/src/i18n/lazy/fields.de.ts b/apps/studio/src/i18n/lazy/fields.de.ts index bc55b2f8..bffe26a1 100644 --- a/apps/studio/src/i18n/lazy/fields.de.ts +++ b/apps/studio/src/i18n/lazy/fields.de.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): de `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: a92d06b266d462cc +// hash: 86531c99468b9a06 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -28,6 +28,8 @@ export const messages: NamespaceMessages<"fields"> = { clear: "Leeren", originalImage: "Das Bild des Originals", sharedList: "Nicht übersetzt: Diese Liste gilt für alle Sprachen.", + beingChanged: + "Wird geändert: Eine laufende Feldänderung wandelt diesen Wert um. Du kannst ihn bearbeiten, sobald die Änderung fertig ist.", originalItems: "{count, plural, one {# Element} other {# Elemente}}", itemLegend: "{named, select, yes {Element {index, number}: {name}} no {Element {index, number}} other {Element {index, number}}}", @@ -79,6 +81,8 @@ export const messages: NamespaceMessages<"fields"> = { "{count, plural, one {# eingeklapptes Element braucht deine Aufmerksamkeit:} other {# eingeklappte Elemente brauchen deine Aufmerksamkeit:}}", removedToast: "{item} entfernt", undo: "Rückgängig", + undoFailed: + "Das Element konnte nicht wiederhergestellt werden: Die Liste ist voll. Entferne zuerst ein Element.", }, reference: { choose: "Eintrag wählen", @@ -96,6 +100,10 @@ export const messages: NamespaceMessages<"fields"> = { menuMoveUp: "Nach oben", menuMoveDown: "Nach unten", remove: "Entfernen", + moved: "{title} an Position {position, number} verschoben", + removed: "{title} entfernt", + loadFailed: "Die gewählten Einträge konnten nicht geladen werden.", + retry: "Erneut versuchen", picker: { title: "{multiple, select, yes {Einträge wählen} no {Eintrag wählen} other {Eintrag wählen}}", diff --git a/apps/studio/src/i18n/lazy/fields.en.ts b/apps/studio/src/i18n/lazy/fields.en.ts index 4254231a..d1d3516c 100644 --- a/apps/studio/src/i18n/lazy/fields.en.ts +++ b/apps/studio/src/i18n/lazy/fields.en.ts @@ -24,6 +24,8 @@ export const messages = { clear: "Clear", originalImage: "The original's image", sharedList: "Not translated: this list is shared by every language.", + beingChanged: + "Being changed: a running field change converts this value. You can edit it once the change is done.", originalItems: "{count, plural, one {# item} other {# items}}", itemLegend: "{named, select, yes {Item {index, number}: {name}} no {Item {index, number}} other {Item {index, number}}}", @@ -73,6 +75,7 @@ export const messages = { "{count, plural, one {# collapsed item needs attention:} other {# collapsed items need attention:}}", removedToast: "{item} removed", undo: "Undo", + undoFailed: "The item couldn't be put back: the list is full. Remove an item first.", }, reference: { choose: "Choose entry", @@ -90,6 +93,10 @@ export const messages = { menuMoveUp: "Move up", menuMoveDown: "Move down", remove: "Remove", + moved: "{title} moved to position {position, number}", + removed: "{title} removed", + loadFailed: "The chosen entries couldn't be loaded.", + retry: "Try again", picker: { title: "{multiple, select, yes {Choose entries} no {Choose an entry} other {Choose an entry}}", diff --git a/apps/studio/src/i18n/lazy/fields.es.ts b/apps/studio/src/i18n/lazy/fields.es.ts index c56b32d2..e8f28b7d 100644 --- a/apps/studio/src/i18n/lazy/fields.es.ts +++ b/apps/studio/src/i18n/lazy/fields.es.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): es `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: dfdce65db2440f39 +// hash: df243de4e4300176 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -28,6 +28,8 @@ export const messages: NamespaceMessages<"fields"> = { clear: "Vaciar", originalImage: "La imagen del original", sharedList: "Sin traducir: esta lista es común a todos los idiomas.", + beingChanged: + "Cambiando: un cambio de campo en curso está convirtiendo este valor. Podrás editarlo cuando el cambio termine.", originalItems: "{count, plural, one {# elemento} many {# de elementos} other {# elementos}}", itemLegend: "{named, select, yes {Elemento {index, number}: {name}} no {Elemento {index, number}} other {Elemento {index, number}}}", @@ -79,6 +81,8 @@ export const messages: NamespaceMessages<"fields"> = { "{count, plural, one {# elemento contraído necesita atención:} many {# de elementos contraídos necesitan atención:} other {# elementos contraídos necesitan atención:}}", removedToast: "{item} quitado", undo: "Deshacer", + undoFailed: + "No se pudo restaurar el elemento: la lista está llena. Quita un elemento primero.", }, reference: { choose: "Elegir entrada", @@ -96,6 +100,10 @@ export const messages: NamespaceMessages<"fields"> = { menuMoveUp: "Subir", menuMoveDown: "Bajar", remove: "Quitar", + moved: "Se movió {title} a la posición {position, number}", + removed: "Se quitó {title}", + loadFailed: "No se pudieron cargar las entradas elegidas.", + retry: "Reintentar", picker: { title: "{multiple, select, yes {Elegir entradas} no {Elegir una entrada} other {Elegir una entrada}}", diff --git a/apps/studio/src/i18n/lazy/fields.fa.ts b/apps/studio/src/i18n/lazy/fields.fa.ts index 1082cb8b..f181041c 100644 --- a/apps/studio/src/i18n/lazy/fields.fa.ts +++ b/apps/studio/src/i18n/lazy/fields.fa.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): fa `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 18df3eeb78dedd95 +// hash: 4ac4df22d9112e28 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -28,6 +28,8 @@ export const messages: NamespaceMessages<"fields"> = { clear: "پاک کردن", originalImage: "تصویر نسخهٔ اصلی", sharedList: "ترجمه نمی‌شود: این فهرست بین همهٔ زبان‌ها مشترک است.", + beingChanged: + "در حال تغییر: یک تغییر فیلد در جریان، این مقدار را تبدیل می‌کند. پس از پایان تغییر می‌توانید آن را ویرایش کنید.", originalItems: "{count, plural, one {# مورد} other {# مورد}}", itemLegend: "{named, select, yes {مورد {index, number}: {name}} no {مورد {index, number}} other {مورد {index, number}}}", @@ -77,6 +79,7 @@ export const messages: NamespaceMessages<"fields"> = { "{count, plural, one {# مورد جمع‌شده نیاز به بررسی دارد:} other {# مورد جمع‌شده نیاز به بررسی دارد:}}", removedToast: "{item} حذف شد", undo: "واگرد", + undoFailed: "مورد برگردانده نشد: فهرست پر است. ابتدا یک مورد را حذف کنید.", }, reference: { choose: "انتخاب ورودی", @@ -94,6 +97,10 @@ export const messages: NamespaceMessages<"fields"> = { menuMoveUp: "انتقال به بالا", menuMoveDown: "انتقال به پایین", remove: "حذف", + moved: "{title} به جایگاه {position, number} منتقل شد", + removed: "{title} حذف شد", + loadFailed: "ورودی‌های انتخاب‌شده بارگذاری نشدند.", + retry: "تلاش دوباره", picker: { title: "{multiple, select, yes {انتخاب ورودی‌ها} no {انتخاب یک ورودی} other {انتخاب یک ورودی}}", diff --git a/apps/studio/src/i18n/lazy/fields.fr.ts b/apps/studio/src/i18n/lazy/fields.fr.ts index f87a9e26..88027c53 100644 --- a/apps/studio/src/i18n/lazy/fields.fr.ts +++ b/apps/studio/src/i18n/lazy/fields.fr.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): fr `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: e70d19aac2c6b99d +// hash: 9b9c7d54ed97280d import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -28,6 +28,8 @@ export const messages: NamespaceMessages<"fields"> = { clear: "Effacer", originalImage: "L’image de l’original", sharedList: "Non traduit : cette liste est commune à toutes les langues.", + beingChanged: + "Modification en cours : un changement de champ convertit cette valeur. Vous pourrez la modifier une fois le changement terminé.", originalItems: "{count, plural, one {# élément} many {# d’éléments} other {# éléments}}", itemLegend: "{named, select, yes {Élément {index, number} : {name}} no {Élément {index, number}} other {Élément {index, number}}}", @@ -80,6 +82,8 @@ export const messages: NamespaceMessages<"fields"> = { "{count, plural, one {# élément replié demande votre attention :} many {# d’éléments repliés demandent votre attention :} other {# éléments repliés demandent votre attention :}}", removedToast: "{item} retiré", undo: "Annuler", + undoFailed: + "Impossible de rétablir l’élément : la liste est pleine. Retirez d’abord un élément.", }, reference: { choose: "Choisir une entrée", @@ -97,6 +101,10 @@ export const messages: NamespaceMessages<"fields"> = { menuMoveUp: "Monter", menuMoveDown: "Descendre", remove: "Retirer", + moved: "{title} déplacé en position {position, number}", + removed: "{title} retiré", + loadFailed: "Impossible de charger les entrées choisies.", + retry: "Réessayer", picker: { title: "{multiple, select, yes {Choisir des entrées} no {Choisir une entrée} other {Choisir une entrée}}", diff --git a/apps/studio/src/i18n/lazy/fields.he.ts b/apps/studio/src/i18n/lazy/fields.he.ts index df7a5e4e..91bb299c 100644 --- a/apps/studio/src/i18n/lazy/fields.he.ts +++ b/apps/studio/src/i18n/lazy/fields.he.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): he `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 11370bdcd190703d +// hash: 57f88a6cab9cd52a import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -28,6 +28,7 @@ export const messages: NamespaceMessages<"fields"> = { clear: "ניקוי", originalImage: "התמונה של המקור", sharedList: "לא מתורגם: הרשימה הזו משותפת לכל השפות.", + beingChanged: "בשינוי: שינוי שדה שרץ ממיר את הערך הזה. אפשר לערוך אותו כשהשינוי יסתיים.", originalItems: "{count, plural, one {פריט אחד} two {שני פריטים} other {# פריטים}}", itemLegend: "{named, select, yes {פריט {index, number}: {name}} no {פריט {index, number}} other {פריט {index, number}}}", @@ -77,6 +78,7 @@ export const messages: NamespaceMessages<"fields"> = { "{count, plural, one {פריט מקופל אחד דורש תשומת לב:} two {שני פריטים מקופלים דורשים תשומת לב:} other {# פריטים מקופלים דורשים תשומת לב:}}", removedToast: "{item} הוסר", undo: "ביטול פעולה", + undoFailed: "לא ניתן היה להחזיר את הפריט: הרשימה מלאה. הסירו קודם פריט.", }, reference: { choose: "בחירת רשומה", @@ -94,6 +96,10 @@ export const messages: NamespaceMessages<"fields"> = { menuMoveUp: "הזזה למעלה", menuMoveDown: "הזזה למטה", remove: "הסרה", + moved: "{title} הועבר למקום {position, number}", + removed: "{title} הוסר", + loadFailed: "לא ניתן היה לטעון את הרשומות שנבחרו.", + retry: "ניסיון חוזר", picker: { title: "{multiple, select, yes {בחירת רשומות} no {בחירת רשומה} other {בחירת רשומה}}", description: "מתוך {collections}.", diff --git a/apps/studio/src/i18n/lazy/fields.hi.ts b/apps/studio/src/i18n/lazy/fields.hi.ts index 6993471f..73559ad8 100644 --- a/apps/studio/src/i18n/lazy/fields.hi.ts +++ b/apps/studio/src/i18n/lazy/fields.hi.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): hi `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 8828b475d3304f32 +// hash: 0a3a4a09af501ba4 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -28,6 +28,8 @@ export const messages: NamespaceMessages<"fields"> = { clear: "साफ़ करें", originalImage: "मूल की इमेज", sharedList: "अनुवाद नहीं होता: यह सूची सभी भाषाओं में साझा है।", + beingChanged: + "बदला जा रहा है: चल रहा एक फ़ील्ड बदलाव इस मान को बदल रहा है। बदलाव पूरा होने पर आप इसे संपादित कर सकते हैं।", originalItems: "{count, plural, one {# आइटम} other {# आइटम}}", itemLegend: "{named, select, yes {आइटम {index, number}: {name}} no {आइटम {index, number}} other {आइटम {index, number}}}", @@ -77,6 +79,7 @@ export const messages: NamespaceMessages<"fields"> = { "{count, plural, one {# समेटे गए आइटम पर ध्यान देना ज़रूरी है:} other {# समेटे गए आइटम पर ध्यान देना ज़रूरी है:}}", removedToast: "{item} हटाया गया", undo: "पूर्ववत करें", + undoFailed: "आइटम वापस नहीं रखा जा सका: सूची भरी हुई है। पहले कोई आइटम हटाएँ।", }, reference: { choose: "एंट्री चुनें", @@ -94,6 +97,10 @@ export const messages: NamespaceMessages<"fields"> = { menuMoveUp: "ऊपर ले जाएँ", menuMoveDown: "नीचे ले जाएँ", remove: "हटाएँ", + moved: "{title} को स्थान {position, number} पर ले जाया गया", + removed: "{title} हटाया गया", + loadFailed: "चुनी गई एंट्री लोड नहीं हो सकीं।", + retry: "फिर से कोशिश करें", picker: { title: "{multiple, select, yes {एंट्री चुनें} no {एक एंट्री चुनें} other {एक एंट्री चुनें}}", description: "{collections} से।", diff --git a/apps/studio/src/i18n/lazy/fields.id.ts b/apps/studio/src/i18n/lazy/fields.id.ts index 1f37959f..4cdc6ef9 100644 --- a/apps/studio/src/i18n/lazy/fields.id.ts +++ b/apps/studio/src/i18n/lazy/fields.id.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): id `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: a17e7d660d1ead02 +// hash: 72420152e87ac4c7 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -28,6 +28,8 @@ export const messages: NamespaceMessages<"fields"> = { clear: "Kosongkan", originalImage: "Gambar versi asli", sharedList: "Tidak diterjemahkan: daftar ini dipakai bersama semua bahasa.", + beingChanged: + "Sedang diubah: perubahan kolom yang berjalan sedang mengonversi nilai ini. Nilai ini bisa diedit setelah perubahan selesai.", originalItems: "{count, plural, other {# item}}", itemLegend: "{named, select, yes {Item {index, number}: {name}} no {Item {index, number}} other {Item {index, number}}}", @@ -76,6 +78,7 @@ export const messages: NamespaceMessages<"fields"> = { invalidItems: "{count, plural, other {# item yang diciutkan perlu diperiksa:}}", removedToast: "{item} dihapus", undo: "Urungkan", + undoFailed: "Item tidak bisa dikembalikan: daftar sudah penuh. Hapus satu item dulu.", }, reference: { choose: "Pilih entri", @@ -93,6 +96,10 @@ export const messages: NamespaceMessages<"fields"> = { menuMoveUp: "Pindah ke atas", menuMoveDown: "Pindah ke bawah", remove: "Hapus", + moved: "{title} dipindahkan ke posisi {position, number}", + removed: "{title} dihapus", + loadFailed: "Entri yang dipilih tidak bisa dimuat.", + retry: "Coba lagi", picker: { title: "{multiple, select, yes {Pilih entri} no {Pilih satu entri} other {Pilih satu entri}}", diff --git a/apps/studio/src/i18n/lazy/fields.it.ts b/apps/studio/src/i18n/lazy/fields.it.ts index 30abdfef..849eb482 100644 --- a/apps/studio/src/i18n/lazy/fields.it.ts +++ b/apps/studio/src/i18n/lazy/fields.it.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): it `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 00d7a63bcfc9fe0b +// hash: 813e78eb09b8af0b import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -28,6 +28,8 @@ export const messages: NamespaceMessages<"fields"> = { clear: "Svuota", originalImage: "L’immagine dell’originale", sharedList: "Non tradotto: questo elenco è comune a tutte le lingue.", + beingChanged: + "Conversione in corso: una modifica di campo sta convertendo questo valore. Potrai modificarlo quando la modifica sarà terminata.", originalItems: "{count, plural, one {# elemento} many {# di elementi} other {# elementi}}", itemLegend: "{named, select, yes {Elemento {index, number}: {name}} no {Elemento {index, number}} other {Elemento {index, number}}}", @@ -79,6 +81,8 @@ export const messages: NamespaceMessages<"fields"> = { "{count, plural, one {# elemento compresso richiede attenzione:} many {# di elementi compressi richiedono attenzione:} other {# elementi compressi richiedono attenzione:}}", removedToast: "{item} rimosso", undo: "Annulla", + undoFailed: + "Impossibile ripristinare l’elemento: l’elenco è pieno. Rimuovi prima un elemento.", }, reference: { choose: "Scegli voce", @@ -96,6 +100,10 @@ export const messages: NamespaceMessages<"fields"> = { menuMoveUp: "Sposta su", menuMoveDown: "Sposta giù", remove: "Rimuovi", + moved: "{title} spostato in posizione {position, number}", + removed: "{title} rimosso", + loadFailed: "Non è stato possibile caricare le voci scelte.", + retry: "Riprova", picker: { title: "{multiple, select, yes {Scegli voci} no {Scegli una voce} other {Scegli una voce}}", description: "Da {collections}.", diff --git a/apps/studio/src/i18n/lazy/fields.ja.ts b/apps/studio/src/i18n/lazy/fields.ja.ts index 2e68795b..97833562 100644 --- a/apps/studio/src/i18n/lazy/fields.ja.ts +++ b/apps/studio/src/i18n/lazy/fields.ja.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ja `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 78206b6fb88017a6 +// hash: c4c678804d4325e0 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -28,6 +28,8 @@ export const messages: NamespaceMessages<"fields"> = { clear: "クリア", originalImage: "オリジナルの画像", sharedList: "翻訳されません:このリストはすべての言語で共通です。", + beingChanged: + "変更中:実行中のフィールドの変更がこの値を変換しています。変更が完了すると編集できます。", originalItems: "{count, plural, other {#件の項目}}", itemLegend: "{named, select, yes {項目 {index, number}:{name}} no {項目 {index, number}} other {項目 {index, number}}}", @@ -77,6 +79,7 @@ export const messages: NamespaceMessages<"fields"> = { invalidItems: "{count, plural, other {折りたたまれた#件の項目に確認が必要です:}}", removedToast: "{item} を削除しました", undo: "元に戻す", + undoFailed: "項目を元に戻せませんでした:リストがいっぱいです。先に項目を削除してください。", }, reference: { choose: "エントリーを選択", @@ -95,6 +98,10 @@ export const messages: NamespaceMessages<"fields"> = { menuMoveUp: "上へ移動", menuMoveDown: "下へ移動", remove: "削除", + moved: "{title} を位置 {position, number} に移動しました", + removed: "{title} を削除しました", + loadFailed: "選択したエントリーを読み込めませんでした。", + retry: "再試行", picker: { title: "{multiple, select, yes {エントリーを選択} no {エントリーを1件選択} other {エントリーを1件選択}}", diff --git a/apps/studio/src/i18n/lazy/fields.ko.ts b/apps/studio/src/i18n/lazy/fields.ko.ts index 63f9f0a8..58d48344 100644 --- a/apps/studio/src/i18n/lazy/fields.ko.ts +++ b/apps/studio/src/i18n/lazy/fields.ko.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ko `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: c734dad53b53c294 +// hash: 5e4e74aa73631583 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -28,6 +28,8 @@ export const messages: NamespaceMessages<"fields"> = { clear: "지우기", originalImage: "원본의 이미지", sharedList: "번역되지 않아요: 이 목록은 모든 언어에서 공유돼요.", + beingChanged: + "변경 중: 진행 중인 필드 변경이 이 값을 변환하고 있어요. 변경이 끝나면 편집할 수 있어요.", originalItems: "{count, plural, other {아이템 #개}}", itemLegend: "{named, select, yes {아이템 {index, number}: {name}} no {아이템 {index, number}} other {아이템 {index, number}}}", @@ -76,6 +78,7 @@ export const messages: NamespaceMessages<"fields"> = { invalidItems: "{count, plural, other {접힌 아이템 #개를 확인해야 해요:}}", removedToast: "{item} 제거됨", undo: "실행 취소", + undoFailed: "아이템을 되돌리지 못했어요: 목록이 가득 찼어요. 먼저 아이템을 제거하세요.", }, reference: { choose: "항목 선택", @@ -93,6 +96,10 @@ export const messages: NamespaceMessages<"fields"> = { menuMoveUp: "위로 이동", menuMoveDown: "아래로 이동", remove: "제거", + moved: "{title} 항목을 {position, number}번 위치로 옮겼어요", + removed: "{title} 항목을 제거했어요", + loadFailed: "선택한 항목을 불러오지 못했어요.", + retry: "다시 시도", picker: { title: "{multiple, select, yes {항목 선택} no {항목 하나 선택} other {항목 하나 선택}}", description: "{collections}에서 선택해요.", diff --git a/apps/studio/src/i18n/lazy/fields.nl.ts b/apps/studio/src/i18n/lazy/fields.nl.ts index 3de745ce..37005a78 100644 --- a/apps/studio/src/i18n/lazy/fields.nl.ts +++ b/apps/studio/src/i18n/lazy/fields.nl.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): nl `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 2042e76e14017cc1 +// hash: 05bd1bbcf46050be import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -28,6 +28,8 @@ export const messages: NamespaceMessages<"fields"> = { clear: "Wissen", originalImage: "De afbeelding van het origineel", sharedList: "Niet vertaald: deze lijst geldt voor alle talen.", + beingChanged: + "Wordt gewijzigd: een lopende veldwijziging zet deze waarde om. Je kunt hem bewerken zodra de wijziging klaar is.", originalItems: "{count, plural, one {# element} other {# elementen}}", itemLegend: "{named, select, yes {Element {index, number}: {name}} no {Element {index, number}} other {Element {index, number}}}", @@ -78,6 +80,8 @@ export const messages: NamespaceMessages<"fields"> = { "{count, plural, one {# ingeklapt element vraagt aandacht:} other {# ingeklapte elementen vragen aandacht:}}", removedToast: "{item} verwijderd", undo: "Ongedaan maken", + undoFailed: + "Het element kon niet worden teruggezet: de lijst is vol. Verwijder eerst een element.", }, reference: { choose: "Item kiezen", @@ -95,6 +99,10 @@ export const messages: NamespaceMessages<"fields"> = { menuMoveUp: "Omhoog", menuMoveDown: "Omlaag", remove: "Verwijderen", + moved: "{title} verplaatst naar positie {position, number}", + removed: "{title} verwijderd", + loadFailed: "De gekozen items konden niet worden geladen.", + retry: "Opnieuw proberen", picker: { title: "{multiple, select, yes {Items kiezen} no {Een item kiezen} other {Een item kiezen}}", diff --git a/apps/studio/src/i18n/lazy/fields.pl.ts b/apps/studio/src/i18n/lazy/fields.pl.ts index 2eedba72..998fb2e2 100644 --- a/apps/studio/src/i18n/lazy/fields.pl.ts +++ b/apps/studio/src/i18n/lazy/fields.pl.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): pl `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 0c09a8098dbc3a85 +// hash: c96c375aa8dfddba import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -28,6 +28,8 @@ export const messages: NamespaceMessages<"fields"> = { clear: "Wyczyść", originalImage: "Obraz oryginału", sharedList: "Nie podlega tłumaczeniu: ta lista jest wspólna dla wszystkich języków.", + beingChanged: + "W trakcie zmiany: trwająca zmiana pola konwertuje tę wartość. Możesz ją edytować, gdy zmiana się zakończy.", originalItems: "{count, plural, one {# element} few {# elementy} many {# elementów} other {# elementu}}", itemLegend: @@ -79,6 +81,7 @@ export const messages: NamespaceMessages<"fields"> = { "{count, plural, one {# zwinięty element wymaga uwagi:} few {# zwinięte elementy wymagają uwagi:} many {# zwiniętych elementów wymaga uwagi:} other {# zwiniętego elementu wymaga uwagi:}}", removedToast: "Usunięto: {item}", undo: "Cofnij", + undoFailed: "Nie udało się przywrócić elementu: lista jest pełna. Najpierw usuń element.", }, reference: { choose: "Wybierz wpis", @@ -96,6 +99,10 @@ export const messages: NamespaceMessages<"fields"> = { menuMoveUp: "Przesuń w górę", menuMoveDown: "Przesuń w dół", remove: "Usuń", + moved: "Wpis {title} przeniesiono na pozycję {position, number}", + removed: "Usunięto: {title}", + loadFailed: "Nie udało się wczytać wybranych wpisów.", + retry: "Spróbuj ponownie", picker: { title: "{multiple, select, yes {Wybierz wpisy} no {Wybierz wpis} other {Wybierz wpis}}", description: "Źródło: {collections}.", diff --git a/apps/studio/src/i18n/lazy/fields.pt-BR.ts b/apps/studio/src/i18n/lazy/fields.pt-BR.ts index 328a7ace..99ef3c6e 100644 --- a/apps/studio/src/i18n/lazy/fields.pt-BR.ts +++ b/apps/studio/src/i18n/lazy/fields.pt-BR.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): pt-BR `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 40c8ad48542a4858 +// hash: b533d0e4c3be5482 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -28,6 +28,8 @@ export const messages: NamespaceMessages<"fields"> = { clear: "Limpar", originalImage: "A imagem do original", sharedList: "Não traduzida: esta lista é comum a todos os idiomas.", + beingChanged: + "Em alteração: uma alteração de campo em andamento está convertendo este valor. Você pode editá-lo quando a alteração terminar.", originalItems: "{count, plural, one {# item} many {# itens} other {# itens}}", itemLegend: "{named, select, yes {Item {index, number}: {name}} no {Item {index, number}} other {Item {index, number}}}", @@ -80,6 +82,7 @@ export const messages: NamespaceMessages<"fields"> = { "{count, plural, one {# item recolhido precisa de atenção:} many {# itens recolhidos precisam de atenção:} other {# itens recolhidos precisam de atenção:}}", removedToast: "Removido: {item}", undo: "Desfazer", + undoFailed: "Não foi possível devolver o item: a lista está cheia. Remova um item primeiro.", }, reference: { choose: "Escolher entrada", @@ -97,6 +100,10 @@ export const messages: NamespaceMessages<"fields"> = { menuMoveUp: "Mover para cima", menuMoveDown: "Mover para baixo", remove: "Remover", + moved: "Movido para a posição {position, number}: {title}", + removed: "Removido: {title}", + loadFailed: "Não foi possível carregar as entradas escolhidas.", + retry: "Tentar de novo", picker: { title: "{multiple, select, yes {Escolher entradas} no {Escolher uma entrada} other {Escolher uma entrada}}", diff --git a/apps/studio/src/i18n/lazy/fields.ru.ts b/apps/studio/src/i18n/lazy/fields.ru.ts index 441e8482..6417eba4 100644 --- a/apps/studio/src/i18n/lazy/fields.ru.ts +++ b/apps/studio/src/i18n/lazy/fields.ru.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ru `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 23715b13eaf3bb1f +// hash: 68b12b1016dd23d4 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -28,6 +28,8 @@ export const messages: NamespaceMessages<"fields"> = { clear: "Очистить", originalImage: "Изображение оригинала", sharedList: "Не переводится: этот список общий для всех языков.", + beingChanged: + "Изменяется: выполняющееся изменение поля преобразует это значение. Вы сможете отредактировать его после завершения изменения.", originalItems: "{count, plural, one {# элемент} few {# элемента} many {# элементов} other {# элемента}}", itemLegend: @@ -79,6 +81,7 @@ export const messages: NamespaceMessages<"fields"> = { "{count, plural, one {# свёрнутый элемент требует внимания:} few {# свёрнутых элемента требуют внимания:} many {# свёрнутых элементов требуют внимания:} other {# свёрнутого элемента требуют внимания:}}", removedToast: "Удалено: {item}", undo: "Отменить", + undoFailed: "Не удалось вернуть элемент: список заполнен. Сначала удалите элемент.", }, reference: { choose: "Выбрать запись", @@ -96,6 +99,10 @@ export const messages: NamespaceMessages<"fields"> = { menuMoveUp: "Переместить вверх", menuMoveDown: "Переместить вниз", remove: "Убрать", + moved: "Перемещено на позицию {position, number}: {title}", + removed: "Удалено: {title}", + loadFailed: "Не удалось загрузить выбранные записи.", + retry: "Повторить", picker: { title: "{multiple, select, yes {Выберите записи} no {Выберите запись} other {Выберите запись}}", diff --git a/apps/studio/src/i18n/lazy/fields.tr.ts b/apps/studio/src/i18n/lazy/fields.tr.ts index f51498bc..6a78ae55 100644 --- a/apps/studio/src/i18n/lazy/fields.tr.ts +++ b/apps/studio/src/i18n/lazy/fields.tr.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): tr `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: bae0f16023e18762 +// hash: 5bfce20b8d17e1f4 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -28,6 +28,8 @@ export const messages: NamespaceMessages<"fields"> = { clear: "Temizle", originalImage: "Orijinalin görseli", sharedList: "Çevrilmez: bu liste tüm dillerde ortak.", + beingChanged: + "Değiştiriliyor: süren bir alan değişikliği bu değeri dönüştürüyor. Değişiklik bitince düzenleyebilirsin.", originalItems: "{count, plural, one {# öğe} other {# öğe}}", itemLegend: "{named, select, yes {Öğe {index, number}: {name}} no {Öğe {index, number}} other {Öğe {index, number}}}", @@ -77,6 +79,7 @@ export const messages: NamespaceMessages<"fields"> = { "{count, plural, one {# daraltılmış öğe ilgi bekliyor:} other {# daraltılmış öğe ilgi bekliyor:}}", removedToast: "Kaldırıldı: {item}", undo: "Geri al", + undoFailed: "Öğe geri konulamadı: liste dolu. Önce bir öğe kaldır.", }, reference: { choose: "Kayıt seç", @@ -94,6 +97,10 @@ export const messages: NamespaceMessages<"fields"> = { menuMoveUp: "Yukarı taşı", menuMoveDown: "Aşağı taşı", remove: "Kaldır", + moved: "{position, number}. sıraya taşındı: {title}", + removed: "Kaldırıldı: {title}", + loadFailed: "Seçilen kayıtlar yüklenemedi.", + retry: "Tekrar dene", picker: { title: "{multiple, select, yes {Kayıtları seç} no {Bir kayıt seç} other {Bir kayıt seç}}", description: "Kaynak: {collections}.", diff --git a/apps/studio/src/i18n/lazy/fields.uk.ts b/apps/studio/src/i18n/lazy/fields.uk.ts index 493322f2..6eaa477e 100644 --- a/apps/studio/src/i18n/lazy/fields.uk.ts +++ b/apps/studio/src/i18n/lazy/fields.uk.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): uk `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 48aff8484cee24f4 +// hash: aed8cd7d8d9c49ea import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -28,6 +28,8 @@ export const messages: NamespaceMessages<"fields"> = { clear: "Очистити", originalImage: "Зображення оригіналу", sharedList: "Не перекладається: цей список спільний для всіх мов.", + beingChanged: + "Змінюється: поточна зміна поля перетворює це значення. Ви зможете редагувати його, коли зміну буде завершено.", originalItems: "{count, plural, one {# елемент} few {# елементи} many {# елементів} other {# елемента}}", itemLegend: @@ -79,6 +81,7 @@ export const messages: NamespaceMessages<"fields"> = { "{count, plural, one {# згорнутий елемент потребує уваги:} few {# згорнуті елементи потребують уваги:} many {# згорнутих елементів потребують уваги:} other {# згорнутого елемента потребують уваги:}}", removedToast: "Видалено: {item}", undo: "Скасувати", + undoFailed: "Не вдалося повернути елемент: список заповнено. Спершу видаліть елемент.", }, reference: { choose: "Вибрати запис", @@ -96,6 +99,10 @@ export const messages: NamespaceMessages<"fields"> = { menuMoveUp: "Перемістити вгору", menuMoveDown: "Перемістити вниз", remove: "Прибрати", + moved: "Переміщено на позицію {position, number}: {title}", + removed: "Видалено: {title}", + loadFailed: "Не вдалося завантажити вибрані записи.", + retry: "Спробувати ще", picker: { title: "{multiple, select, yes {Виберіть записи} no {Виберіть запис} other {Виберіть запис}}", diff --git a/apps/studio/src/i18n/lazy/fields.zh-Hans.ts b/apps/studio/src/i18n/lazy/fields.zh-Hans.ts index bd71af76..4872f6c9 100644 --- a/apps/studio/src/i18n/lazy/fields.zh-Hans.ts +++ b/apps/studio/src/i18n/lazy/fields.zh-Hans.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): zh-Hans `fields.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 821b73dcabd1f0ba +// hash: 075911861dff4694 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"fields"> = { @@ -28,6 +28,7 @@ export const messages: NamespaceMessages<"fields"> = { clear: "清除", originalImage: "原文的图片", sharedList: "无需翻译:此列表在所有语言间共享。", + beingChanged: "正在更改:一项进行中的字段更改正在转换此值。更改完成后即可编辑。", originalItems: "{count, plural, other {# 个项目}}", itemLegend: "{named, select, yes {第 {index, number} 项:{name}} no {第 {index, number} 项} other {第 {index, number} 项}}", @@ -75,6 +76,7 @@ export const messages: NamespaceMessages<"fields"> = { invalidItems: "{count, plural, other {# 个已折叠的项目需要处理:}}", removedToast: "已移除:{item}", undo: "撤销", + undoFailed: "无法放回该项:列表已满。请先移除一项。", }, reference: { choose: "选择条目", @@ -92,6 +94,10 @@ export const messages: NamespaceMessages<"fields"> = { menuMoveUp: "上移", menuMoveDown: "下移", remove: "移除", + moved: "{title} 已移至第 {position, number} 位", + removed: "已移除:{title}", + loadFailed: "无法加载所选条目。", + retry: "重试", picker: { title: "{multiple, select, yes {选择条目} no {选择一个条目} other {选择一个条目}}", description: "来自{collections}。", diff --git a/apps/studio/src/lib/empty-items.ts b/apps/studio/src/lib/empty-items.ts new file mode 100644 index 00000000..0b118f2e --- /dev/null +++ b/apps/studio/src/lib/empty-items.ts @@ -0,0 +1,30 @@ +/** + * RFC-0045 §1 "no empty item": the store refuses a repeater item without a value, and "Add item" + * adds one that the person is about to fill. The editor keeps it while they type and drops it from + * what a save sends; a list left with no item is no value. Only the collection's repeater + * definitions are touched (a collection without definitions may store any JSON). + * + * Dependency-free on purpose: the editor's own chunk imports it, never the fields chunk. + */ +import type { ContentEntry, Site } from "@cloudhal/core-contracts"; + +const isItem = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v); + +/** The entry's fields without empty repeater items (the same entry when there are none). */ +export function withoutEmptyItems(entry: ContentEntry, site: Site | undefined): ContentEntry { + const defs = site?.collections?.[entry.collection]?.fields ?? []; + const fields = entry.fields; + if (!fields) return entry; + let next: Record | undefined; + for (const def of defs) { + const v = fields[def.key]; + if (def.type !== "repeater" || !Array.isArray(v)) continue; + const kept = v.filter((item) => !isItem(item) || Object.keys(item).length > 0); + if (kept.length === v.length) continue; + next ??= { ...fields }; + if (kept.length) next[def.key] = kept; + else delete next[def.key]; + } + return next ? { ...entry, fields: next } : entry; +} diff --git a/apps/studio/src/lib/field-references.ts b/apps/studio/src/lib/field-references.ts index 8a43018c..420c115e 100644 --- a/apps/studio/src/lib/field-references.ts +++ b/apps/studio/src/lib/field-references.ts @@ -6,7 +6,7 @@ * Loads with the fields UI only (`bundle-budget.ts` `LAZY_ONLY`). */ import type { FieldDefinition, Site } from "@cloudhal/core-contracts"; -import { fieldLabel } from "@cloudhal/core-ts/fields"; +import { fieldLabel, renamedFrom } from "@cloudhal/core-ts/fields"; import type { ReferenceCard } from "./api/references.ts"; import type { FieldIssue } from "./fields.ts"; @@ -16,19 +16,30 @@ const ids = (v: unknown): string[] => const isItem = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v); -/** Every group id the values name, in references and in repeater items' reference sub-fields. */ +/** + * Every group id the values name, read as the form shows them: through a running rename's old key + * (top level, or a sub-field's in each item), in references and repeater items' reference + * sub-fields. Strings that are no group id are returned too; the groups read leaves them out. + */ export function referenceIdsIn( definitions: readonly FieldDefinition[], values: Readonly> | undefined, ): string[] { const out = new Set(); for (const def of definitions) { - const v = values?.[def.key]; + const from = renamedFrom(def); + const v = values?.[def.key] ?? (from ? values?.[from] : undefined); if (def.type === "reference") for (const id of ids(v)) out.add(id); if (def.type !== "repeater" || !Array.isArray(v)) continue; - const subs = def.fields.filter((s) => s.type === "reference").map((s) => s.key); - for (const item of v.filter(isItem)) - for (const key of subs) for (const id of ids(item[key])) out.add(id); + const m = def.migration; + const subFrom = m?.sub !== undefined && m.fromKey ? { sub: m.sub, from: m.fromKey } : undefined; + for (const sub of def.fields) { + if (sub.type !== "reference") continue; + for (const item of v.filter(isItem)) { + const value = item[sub.key] ?? (subFrom?.sub === sub.key ? item[subFrom.from] : undefined); + for (const id of ids(value)) out.add(id); + } + } } return [...out]; } diff --git a/apps/studio/src/routes/ArticleEditorPage.tsx b/apps/studio/src/routes/ArticleEditorPage.tsx index d635143e..929dda2f 100644 --- a/apps/studio/src/routes/ArticleEditorPage.tsx +++ b/apps/studio/src/routes/ArticleEditorPage.tsx @@ -1,4 +1,4 @@ -import type { ContentEntry, PublishStatus } from "@cloudhal/core-contracts"; +import type { ContentEntry, PublishStatus, Site } from "@cloudhal/core-contracts"; import { collectionPermalink, permalinkPrefix } from "@cloudhal/core-ts/permalinks"; import { ApprovalDialog, @@ -62,6 +62,7 @@ import { useSavedToast } from "../components/useLiveSite.ts"; import { useCan, useGate, useSelf } from "../components/useMe.ts"; import { useT } from "../i18n/index.tsx"; import { api, type NewEntry } from "../lib/api.ts"; +import { withoutEmptyItems } from "../lib/empty-items.ts"; import { entryRoute } from "../lib/entry-href.ts"; import { entryAccess } from "../lib/roles.ts"; import { cleanImageRef, hasErrors, SEO_LIMITS, validateImageRef } from "../lib/seo.ts"; @@ -91,8 +92,9 @@ function duplicateOf(entry: ContentEntry, title: string): NewEntry { const wordCount = (text: string) => text.split(/\s+/).filter(Boolean).length; -/** What is sent: the cover image's fields trimmed. */ -function forSave(entry: ContentEntry): ContentEntry { +/** What is sent: the cover image's fields trimmed, and no empty repeater item (RFC-0045 §1). */ +function forSave(draft: ContentEntry, site: Site | undefined): ContentEntry { + const entry = withoutEmptyItems(draft, site); return entry.coverImage ? { ...entry, coverImage: cleanImageRef(entry.coverImage) } : entry; } @@ -181,7 +183,7 @@ export function ArticleEditorPage() { const self = useSelf(); const save = useMutation({ - mutationFn: (entry: ContentEntry) => api.entries.update(forSave(entry)), + mutationFn: (entry: ContentEntry) => api.entries.update(forSave(entry, site.data)), onSuccess: async (saved) => { setDraft(saved); setShowErrors(false); @@ -447,7 +449,7 @@ export function ArticleEditorPage() { siteId={siteId} site={site.data} entry={draft} - onChange={(fields) => setDraft({ ...draft, fields })} + onChange={(fields) => setDraft((d) => d && { ...d, fields })} error={save.error} disabled={readOnlyReason !== undefined || locked} /> diff --git a/apps/studio/test/fields-references.test.tsx b/apps/studio/test/fields-references.test.tsx index e54ce220..f912c668 100644 --- a/apps/studio/test/fields-references.test.tsx +++ b/apps/studio/test/fields-references.test.tsx @@ -4,12 +4,14 @@ * with an undo toast, item errors by path, and the "Used by" card. */ import type { ContentEntry, FieldDefinition, Site } from "@cloudhal/core-contracts"; +import { Button } from "@cloudhal/ui"; import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { EntryFieldCards } from "../src/components/EntryFieldCards.tsx"; import { EntryFieldsCard } from "../src/components/fields/EntryFieldsCard.tsx"; import { ApiRequestError } from "../src/lib/api/http.ts"; +import { withoutEmptyItems } from "../src/lib/empty-items.ts"; import { on, serve, wrap } from "./roles-fixtures.tsx"; afterEach(() => { @@ -194,6 +196,129 @@ describe("Entry editor › repeater fields", () => { }); }); +/** The editor's own way of holding the draft: functional updates, title and fields together. */ +function EditorHarness({ + entry, + onDraft, +}: { + entry: ContentEntry; + onDraft: (d: ContentEntry) => void; +}) { + const [draft, setDraft] = useState(entry); + const [shown, setShown] = useState(entry.id); + if (shown !== entry.id) { + setShown(entry.id); + setDraft(entry); + } + if (draft) onDraft(draft); + if (!draft) return null; + return ( + <> + + setDraft((d) => d && { ...d, fields })} + error={undefined} + disabled={false} + /> + + ); +} + +describe("Entry editor › repeater undo (review fixes)", () => { + it("keeps every edit made after the removal", async () => { + serve(groups({ entry_hall0001: [card("entry_hall0001", "Main hall")] })); + const onDraft = vi.fn(); + render(wrap()); + const item = await screen.findByRole("group", { name: "Item 1: Ada" }); + fireEvent.click(within(item).getByRole("button", { name: "Actions for item 1" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "Remove" })); + fireEvent.click(screen.getByRole("button", { name: "Retitle" })); + fireEvent.click(await screen.findByRole("button", { name: "Undo" })); + await waitFor(() => + expect(onDraft).toHaveBeenLastCalledWith( + expect.objectContaining({ + title: "Renamed", + fields: { venue: "entry_hall0001", speakers: [{ name: "Ada", role: "Host" }] }, + }), + ), + ); + }); + + it("an undo from another entry changes nothing, and its toast closes", async () => { + serve(groups({ entry_hall0001: [card("entry_hall0001", "Main hall")] })); + const onDraft = vi.fn(); + const other = { ...ENTRY, id: "entry_event002", title: "Other", fields: {} } as ContentEntry; + const { rerender } = render(wrap()); + const item = await screen.findByRole("group", { name: "Item 1: Ada" }); + fireEvent.click(within(item).getByRole("button", { name: "Actions for item 1" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "Remove" })); + await screen.findByRole("button", { name: "Undo" }); + rerender(wrap()); + await waitFor(() => expect(screen.queryByRole("button", { name: "Undo" })).toBeNull()); + expect(onDraft).toHaveBeenLastCalledWith(other); + }); +}); + +describe("Entry editor › reference fields when the targets can't be read", () => { + it("says so with a retry, instead of loading forever", async () => { + let fail = true; + serve( + on("GET", "/api/sites/site_1/entries/groups", () => + fail + ? { status: 500, body: { error: "internal", message: "boom" } } + : { + status: 200, + body: { groups: { entry_hall0001: [card("entry_hall0001", "Main hall")] } }, + }, + ), + ); + render(wrap()); + const venue = await screen.findByRole("group", { name: "Venue" }); + expect( + await within(venue).findByText( + "The chosen entries couldn't be loaded.", + {}, + { timeout: 5000 }, + ), + ).toBeInTheDocument(); + fail = false; + fireEvent.click(within(venue).getByRole("button", { name: "Try again" })); + expect(await within(venue).findByRole("link", { name: "Main hall" })).toBeInTheDocument(); + }); + + it("never sends a value that is no group id; it shows as Not found", async () => { + const fetch = serve(groups({ entry_hall0001: [card("entry_hall0001", "Main hall")] })); + render( + wrap( + , + ), + ); + const venue = await screen.findByRole("group", { name: "Venue" }); + expect(await within(venue).findByText("Not found")).toBeInTheDocument(); + expect(fetch.mock.calls.some(([u]) => String(u).includes("/entries/groups"))).toBe(false); + }); +}); + +describe("Saving drops empty repeater items (RFC-0045 §1)", () => { + it("keeps items with a value and drops a list left empty", () => { + const entry = { + ...ENTRY, + fields: { speakers: [{}, { name: "Ada" }, {}], venue: "entry_hall0001", other: [{}] }, + } as unknown as ContentEntry; + expect(withoutEmptyItems(entry, SITE).fields).toEqual({ + speakers: [{ name: "Ada" }], + venue: "entry_hall0001", + other: [{}], + }); + const empty = { ...ENTRY, fields: { speakers: [{}] } } as unknown as ContentEntry; + expect(withoutEmptyItems(empty, SITE).fields).toEqual({}); + expect(withoutEmptyItems(ENTRY, SITE)).toBe(ENTRY); + }); +}); + describe("Entry editor › Used by", () => { it("lists referrers where some field can reference the collection", async () => { serve( diff --git a/docs/design/DESIGN.md b/docs/design/DESIGN.md index 1b91b52a..876d089f 100644 --- a/docs/design/DESIGN.md +++ b/docs/design/DESIGN.md @@ -479,7 +479,7 @@ Site › Settings gets an **Appearance** section after Domains (`components/Appe ### 2ah. Typed custom fields (RFC-0041 §7, ADR-0043) Words in the lazy `fields` namespace (loaded with the entry editor and collection settings routes). The UI (`components/fields/`, `lib/fields.ts`, `lib/field-form.ts`, the `EntryFieldsForm` block and `zoned-time.ts`, `@cloudhal/core-ts/fields`) is `LAZY_ONLY` in `bundle-budget.ts` and loads only for a collection that has definitions. - **Entry editor › Fields** (`EntryFieldsCard`, a `SectionCard` after "Article", only when the collection defines fields): the `EntryFieldsForm` block, saved with the entry (Save and the status actions send `fields`). One control per type: text → `Input` (`InputArea` when `multiline`; `maxLength` on the control), rich text → the body editor (`BodyEditor size="sm"`, lazy Tiptap) under a `Label`, number → `Input type=number` (min/max/step from the definition), boolean → `Switch`, date → `DateTimeField dateOnly`, datetime → `DateTimeField` in the value's own zone (the site's for a new value; stored as `{ at, timeZone }`), URL → `Input type=url`, image → `FieldImage` (thumbnail, title and size in an `InsetPanel row`, "Choose image" / "Change" / "Remove", the editor's `AssetPickerDialog`), select → `Select` with a "No value" choice, or a `Checkbox.Group` when `multiple`. Dates get a ghost "Clear". Labels and help come from the definition in the Studio language (`fieldLabel` / `fieldHelp` / `optionLabel`, then the site's default language); the description starts with an outline `Badge` "Needed to publish" for `requiredToPublish`, then help and the limits in words ("Up to 80 characters.", "From 1 to 10."). The last save's `details.issues` show on their field (`fields//…` → that field), worded per code (`fields.issues.*`); the save error itself stays in the page's `ErrorBanner`. **Translations** (the group's original in the same site): non-localized fields show their control disabled with the original's value and "From the original (English) · Open the original" (a `Link` to its editor); empty localized fields show the original's value as their placeholder, or "The original: …" under controls without one (choices, dates, yes/no, images). -- **Entry editor › references and repeaters** (RFC-0045 §5; words in `fields.reference.*`, `fields.repeater.*`, `fields.usedBy.*`): a reference is `FieldReference` — `ReferenceList` of the stored targets (each group's member in the entry's language, else the default language's, else the first, read from `GET …/entries/groups`; any status, so drafts and archived ones say they will not show and deleted ones say "Not found") and an `EntryPicker` over `GET …/entries/pick` (the allowed collections, keyset cursor; the entry's own group and the chosen ones can't be picked); the trigger is secondary "Choose entry", "Change" for a chosen single target, "Add entries" with "Holds up to n entries." for several. A repeater is `RepeaterField` with each sub-field's `FieldControl` (images through `FieldImage`, references through `FieldReference`); its legend is `fields.editor.itemLegend` ("Item 2: Ada Lovelace", from `itemLabel`), "Remove" shows an info toast "Item 2: … removed" with "Undo" for 10 s, and a list that is not localized says "Not translated: this list is shared by every language." on multilingual sites. Save issues map by path (`fields/speakers/2/person` → that control). **Used by** (`UsedByCard`, a flush `SectionCard` after Fields, only when some field of the site can reference the collection): `UsedByList` from `GET …/entries/:id/references`, published referrers first, "Show more" while pages remain, `SectionEmpty` (`LinkSimple`) when none. Both cards load through `EntryFieldCards` as their own lazy chunks. +- **Entry editor › references and repeaters** (RFC-0045 §5; words in `fields.reference.*`, `fields.repeater.*`, `fields.usedBy.*`): a reference is `FieldReference` — `ReferenceList` of the stored targets (each group's member in the entry's language, else the default language's, else the first, read from `GET …/entries/groups`; any status, so drafts and archived ones say they will not show and deleted ones say "Not found") and an `EntryPicker` over `GET …/entries/pick` (the allowed collections, keyset cursor; the entry's own group and the chosen ones can't be picked); the trigger is secondary "Choose entry", "Change" for a chosen single target, "Add entries" with "Holds up to n entries." for several. A repeater is `RepeaterField` with each sub-field's `FieldControl` (images through `FieldImage`, references through `FieldReference`); its legend is `fields.editor.itemLegend` ("Item 2: Ada Lovelace", from `itemLabel`), "Remove" shows an info toast "Item 2: … removed" with "Undo" for 10 s (scoped to the entry: it closes when the editor leaves it; Undo writes over the current draft, and an error toast says when the list is full again), a save leaves out items that hold no value, a value a running type change has not converted shows read-only, as stored, with "Being changed", and a list that is not localized says "Not translated: this list is shared by every language." on multilingual sites. Save issues map by path (`fields/speakers/2/person` → that control). **Used by** (`UsedByCard`, a flush `SectionCard` after Fields, only when some field of the site can reference the collection): `UsedByList` from `GET …/entries/:id/references`, published referrers first, "Show more" while pages remain, `SectionEmpty` (`LinkSimple`) when none. Both cards load through `EntryFieldCards` as their own lazy chunks. - **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). diff --git a/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md b/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md index a490fd42..92b7e890 100644 --- a/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md +++ b/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md @@ -1399,3 +1399,13 @@ Round 1 dropped the "fits" shortcut for text ↔ reference, but then the table r - **`EntryPicker`** marks several-choice options with a checkbox icon inside each `option` (the search field keeps the focus, `aria-activedescendant`), not a Kumo `Checkbox`, so the listbox has no nested controls. One choice picks and closes. A group found in several languages is listed once. - **`FieldControl`** frames a reference in a `fieldset` (label, description, error); the host's `renderReference` fills it. - **Gate:** step 6a adds no capability check. No production collection can define the two types before step 7, so the editor never meets them there; the builder (6b) offers them only behind the server's capability. + +#### Review round 1 (2026-11-13, PM review of PR #219) + +- **Undo writes over the present.** The repeater's undo writes through the latest `onChange` and items, `EntryFieldsForm` writes over its latest value, and the editor updates its draft functionally. An undo seconds later therefore keeps every edit made since the removal (title, body, other fields) and the saved `rev`. The toast belongs to its entry: it closes when the editor leaves the entry or unmounts, and an undo for another entry does nothing. Undo answers false, and Studio says so, when the list is full again or gone. +- **Item problems follow their items.** The error map arrives by index and is re-keyed to the items' stable keys once, so moves, removals and undos keep each problem, and `aria-invalid`, on its own item. +- **Announcements** empty the polite region before each message, so a repeated message is announced again (repeater, and `ReferenceList`, whose moves and removals now keep focus on the moved row or move it to the next row, else to the picker's trigger). +- **`EntryPicker`.** A full field keeps its trigger focusable (`aria-disabled`, opens nothing), so focus returns to it after the pick that fills the field. A failed search is the polite status ("The search didn't finish"), and the spinner is hidden from assistive tech. +- **The groups read** sends only well-formed group ids. Anything else (text a running conversion has not rewritten) shows as "Not found". A failed read shows an error banner with "Try again". +- **During a running field operation** the editor reads values through the markers. A top-level rename's old key and a sub-field rename's old key in each item are shown under the new key, and a write leaves only the new key. A value a type change has not converted yet (its shape is not the new type's, `fitsShape`) is shown read-only, as stored, with "Being changed", top level or per item. The UI never converts it; phase B does. +- **No empty item is sent.** "Add item" keeps its empty item in the form while it is being filled; the save leaves out items without a value (the collection's repeater definitions only), and a list left empty is no value. diff --git a/packages/ui/src/blocks/EntryFieldsForm.tsx b/packages/ui/src/blocks/EntryFieldsForm.tsx index 77ecf227..40ec4abf 100644 --- a/packages/ui/src/blocks/EntryFieldsForm.tsx +++ b/packages/ui/src/blocks/EntryFieldsForm.tsx @@ -1,6 +1,6 @@ import type { FieldDefinition } from "@cloudhal/core-contracts"; import { fieldLabel } from "@cloudhal/core-ts/fields"; -import { type ReactNode, useMemo } from "react"; +import { type ReactNode, useMemo, useRef } from "react"; import { EntryRepeater, type EntryRepeaterLabels } from "./EntryRepeater.tsx"; import { errorUnder, @@ -11,6 +11,7 @@ import { type RepeaterErrors, readField, repeaterErrors, + unconverted, writeField, } from "./entry-fields-model.ts"; import { @@ -75,8 +76,11 @@ export interface EntryFieldsFormProps { renderReference?: (slot: FieldReferenceSlot) => ReactNode; /** A target's title, when the host knows it (repeater legends, a translation's fallback line). */ referenceTitle?: (groupId: string) => string | undefined; - /** After a repeater item is removed: the host offers `undo` (Studio: a toast). */ - onItemRemoved?: (removed: { field: string; legend: string; undo: () => void }) => void; + /** + * After a repeater item is removed: the host offers `undo` (Studio: a toast), which answers false + * when it could not put the item back (the list is gone or full). + */ + onItemRemoved?: (removed: { field: string; legend: string; undo: () => boolean }) => void; disabled?: boolean; } @@ -97,16 +101,19 @@ function fieldState( const emptyLocalized = original !== undefined && def.localized && !hasValue(own); const fallback = emptyLocalized && hasValue(theirs) ? fallbackText(def, theirs, fallbackContext) : undefined; + const shown = inherited ? theirs : own; + const changing = unconverted(def, def, shown); const shared = def.type === "repeater" && !def.localized && props.multilingual === true; const description = describeField(def, context, { hint: props.hint?.(def), fallback, inherited: inherited && original ? { href: original.href } : undefined, - note: shared ? props.labels.sharedList : undefined, + note: changing ? props.labels.beingChanged : shared ? props.labels.sharedList : undefined, }); return { inherited, - shown: inherited ? theirs : own, + changing, + shown, fallback, fallbackValue: emptyLocalized ? theirs : undefined, description, @@ -144,6 +151,8 @@ export function EntryFieldsForm(props: EntryFieldsFormProps) { }; // One map per error object, so a repeater opens its invalid items once per failed save. const { errors } = props; + const latest = useRef({ value, onChange }); + latest.current = { value, onChange }; const nested = useMemo(() => { const out = new Map(); for (const def of definitions) @@ -153,18 +162,20 @@ export function EntryFieldsForm(props: EntryFieldsFormProps) { return (
{definitions.map((def) => { - const { inherited, shown, fallback, fallbackValue, description } = fieldState( + const { inherited, changing, shown, fallback, fallbackValue, description } = fieldState( def, props, context, fallbackContext, ); const label = fieldLabel(def, locale, defaultLocale); - const readOnly = props.disabled === true || inherited; - const set = (v: unknown) => onChange(writeField(value, def, v)); + const readOnly = props.disabled === true || inherited || changing; + // Over the latest value and handler: a repeater's undo can run seconds later. + const set = (v: unknown) => + latest.current.onChange(writeField(latest.current.value, def, v)); return (
- {def.type === "repeater" ? ( + {def.type === "repeater" && !changing ? ( )} diff --git a/packages/ui/src/blocks/EntryPicker.tsx b/packages/ui/src/blocks/EntryPicker.tsx index 0bc28c93..78f50bea 100644 --- a/packages/ui/src/blocks/EntryPicker.tsx +++ b/packages/ui/src/blocks/EntryPicker.tsx @@ -1,6 +1,14 @@ import { Badge, Button, cn, Input, LayerDialog, Loader, Text } from "@cloudflare/kumo"; import { CheckSquare, Square } from "@phosphor-icons/react"; -import { type KeyboardEvent, type ReactElement, useEffect, useId, useRef, useState } from "react"; +import { + cloneElement, + type KeyboardEvent, + type ReactElement, + useEffect, + useId, + useRef, + useState, +} from "react"; import { SectionEmpty } from "./SectionEmpty.tsx"; /** One search result (the picker route's card, worded by the host). */ @@ -52,12 +60,17 @@ export interface EntryPickerLabels { export interface EntryPickerProps { /** The button that opens the picker; focus returns to it when the picker closes. */ - trigger: ReactElement; + trigger: ReactElement<{ className?: string; "aria-disabled"?: boolean }>; /** Searches the allowed collections (Studio: the picker route). The block makes no API calls. */ search: (q: string, cursor: string | undefined) => Promise; /** Several targets at once (up to `remaining`), or one. */ multiple: boolean; remaining: number; + /** + * The field holds all it can: the trigger stays focusable (`aria-disabled`) and opens nothing, + * so focus is not lost when a pick fills the field. + */ + disabled?: boolean | undefined; /** Group ids that can't be chosen: already chosen ones, the entry's own group. */ unavailable?: ReadonlySet | undefined; onPick: (items: EntryPickerItem[]) => void; @@ -174,10 +187,21 @@ export function EntryPicker(props: EntryPickerProps) { }; const busy = results.state === "loading"; - const said = busy ? labels.searching : labels.results(items.length); + const said = busy + ? labels.searching + : results.state === "failed" + ? labels.failed + : labels.results(items.length); + // A full field keeps its trigger focusable (focus returns to it after a pick), but it opens nothing. + const trigger = props.disabled + ? cloneElement(props.trigger, { + "aria-disabled": true, + className: cn(props.trigger.props.className, "cursor-not-allowed opacity-50"), + }) + : props.trigger; return ( - - + setOpen(next && !props.disabled)}> + {labels.title} {labels.description && ( @@ -200,19 +224,23 @@ export function EntryPicker(props: EntryPickerProps) { description={multiple ? labels.remaining(props.remaining - chosen.length) : undefined} /> - + {said} {busy ? (
- + {/* The status above says "Searching…"; the spinner is only seen. */} +
- ) : results.state === "failed" && items.length === 0 ? ( - - {labels.failed} - - ) : items.length === 0 && !results.cursor ? ( + ) : results.state === "failed" && items.length === 0 ? null : items.length === 0 && + !results.cursor ? ( ) : (
number> = { End: (_, last) => last, }; -/** "Show more", or "Keep searching" after a truncated read, and a failed later page. */ +/** "Show more", or "Keep searching" after a truncated read (a failure is the status's to say). */ function MoreResults({ results, more, @@ -294,11 +322,6 @@ function MoreResults({
)} - {results.state === "failed" && results.items.length > 0 && ( - - {labels.failed} - - )} ); } diff --git a/packages/ui/src/blocks/EntryRepeater.tsx b/packages/ui/src/blocks/EntryRepeater.tsx index f526cffa..ce0321c7 100644 --- a/packages/ui/src/blocks/EntryRepeater.tsx +++ b/packages/ui/src/blocks/EntryRepeater.tsx @@ -1,7 +1,7 @@ import type { RepeaterField as RepeaterDefinition } from "@cloudhal/core-contracts"; import { FIELD_LIMITS, fieldLabel } from "@cloudhal/core-ts/fields"; import type { ReactNode } from "react"; -import { itemName, type RepeaterErrors, repeaterItems } from "./entry-fields-model.ts"; +import { itemName, type RepeaterErrors, shownItems, unconverted } from "./entry-fields-model.ts"; import { describeField, FieldControl, type FieldControlContext } from "./FieldControl.tsx"; import { RepeaterField, type RepeaterFieldLabels } from "./RepeaterField.tsx"; @@ -23,7 +23,9 @@ export interface EntryRepeaterProps { context: FieldControlContext; labels: EntryRepeaterLabels; referenceTitle?: ((groupId: string) => string | undefined) | undefined; - onRemoved?: ((removed: { index: number; legend: string; undo: () => void }) => void) | undefined; + onRemoved?: + | ((removed: { index: number; legend: string; undo: () => boolean }) => void) + | undefined; } /** @@ -44,7 +46,7 @@ export function EntryRepeater(props: EntryRepeaterProps) { label={props.label} description={props.description} error={props.errors.field} - items={repeaterItems(props.value)} + items={shownItems(def, props.value)} onChange={props.onChange} subFields={def.fields} max={def.maxItems ?? FIELD_LIMITS.listItems} @@ -53,19 +55,26 @@ export function EntryRepeater(props: EntryRepeaterProps) { legend={(i, item) => props.labels.itemLegend(i + 1, itemName(def, item, names))} onRemoved={props.onRemoved} labels={props.labels.repeater} - renderControl={({ sub, index, value, onChange, error, readOnly }) => ( - - )} + renderControl={({ sub, index, value, onChange, error, readOnly }) => { + // A sub-field a running type change has not converted in this item yet: as stored. + const changing = unconverted(def, sub, value); + return ( + + ); + }} /> ); } diff --git a/packages/ui/src/blocks/FieldControl.tsx b/packages/ui/src/blocks/FieldControl.tsx index 1305e37b..3c824689 100644 --- a/packages/ui/src/blocks/FieldControl.tsx +++ b/packages/ui/src/blocks/FieldControl.tsx @@ -44,6 +44,8 @@ export interface FieldControlLabels { originalImage: string; /** The date and time controls' words (`DateTimeField`). */ date: DateTimeFieldLabels; + /** Under a value a running type change has not converted yet (shown read-only). */ + beingChanged: string; } /** What the host renders for a rich text field (Studio: the lazy markdown body editor). */ @@ -123,6 +125,8 @@ export interface FieldControlProps { placeholder?: string | undefined; /** The original's value itself, for controls that show it their own way (references). */ fallbackValue?: unknown; + /** Show the stored value read-only, as it is: a value a running type change has not converted. */ + asStored?: boolean | undefined; context: FieldControlContext; } @@ -493,6 +497,6 @@ export function describeField( * ids read-only). `EntryFieldsForm` uses it for fields, `RepeaterField` items for sub-fields. */ export function FieldControl(props: FieldControlProps) { - const Control = CONTROLS[props.def.type]; + const Control = props.asStored ? StoredValueControl : CONTROLS[props.def.type]; return ; } diff --git a/packages/ui/src/blocks/ReferenceList.tsx b/packages/ui/src/blocks/ReferenceList.tsx index a9321f42..f688d424 100644 --- a/packages/ui/src/blocks/ReferenceList.tsx +++ b/packages/ui/src/blocks/ReferenceList.tsx @@ -1,5 +1,7 @@ import { Badge, Button, DropdownMenu, Link, SkeletonLine, Text } from "@cloudflare/kumo"; import { ArrowDown, ArrowSquareOut, ArrowUp, DotsThree, Trash } from "@phosphor-icons/react"; +import { useLayoutEffect, useRef } from "react"; +import { useAnnouncer } from "./announcer.ts"; import { directional } from "./DirectionalIcon.tsx"; import { InsetPanel } from "./InsetPanel.tsx"; @@ -42,6 +44,9 @@ export interface ReferenceListLabels { remove: string; /** Read while loading (the row's accessible name). */ loading: string; + /** Announced (polite): "Main hall moved to position 2", "Main hall removed". */ + moved: (title: string, position: number) => string; + removed: (title: string) => string; } export interface ReferenceListProps { @@ -53,6 +58,8 @@ export interface ReferenceListProps { readOnly?: boolean; onMove?: (index: number, by: -1 | 1) => void; onRemove?: (index: number) => void; + /** Where focus goes when the last row is removed (Studio: the picker's trigger). */ + focusAfterLast?: () => HTMLElement | null | undefined; labels: ReferenceListLabels; } @@ -68,7 +75,9 @@ const STATUS_BADGE = { * title (a link to its editor), the collection and language, and a "Draft", "In review" or * "Archived" badge with "Will not show until published"; "Not found" for an id with no entry left. * Per row: move up and down (icon buttons from `sm` when sortable, also in the ⋯ menu), open and - * remove. Badges wrap under the title on narrow screens. + * remove. Badges wrap under the title on narrow screens. After a move, focus stays on the moved + * row (its move button, else its ⋯); after a remove it goes to the next row, else to + * `focusAfterLast` (Studio: the picker's trigger). Both are announced in a polite live region. */ export function ReferenceList({ label, @@ -77,134 +86,201 @@ export function ReferenceList({ readOnly = false, onMove, onRemove, + focusAfterLast, labels, }: ReferenceListProps) { + const root = useRef(null); + const focus = useRef<{ id: string; at: "up" | "down" | "actions" } | "after" | null>(null); + const [said, say] = useAnnouncer(); + useLayoutEffect(() => { + const want = focus.current; + focus.current = null; + if (want === "after") return void focusAfterLast?.()?.focus(); + if (!want) return; + const row = root.current?.querySelector(`[data-reference="${CSS.escape(want.id)}"]`); + const pick = (selector: string) => row?.querySelector(selector); + const target = + want.at === "actions" + ? undefined + : (pick(`[data-reference-move="${want.at}"]:not([disabled])`) ?? + pick("[data-reference-move]:not([disabled])")); + (target ?? pick("[data-reference-actions]"))?.focus(); + }); + const name = (item: ReferenceListItem) => item.title ?? item.id; + const move = (i: number, by: -1 | 1) => { + const item = items[i] as ReferenceListItem; + // A row moved from the menu keeps focus on its ⋯; one moved by its arrow, on an arrow. + focus.current = { + id: item.id, + at: document.activeElement?.hasAttribute("data-reference-move") + ? by < 0 + ? "up" + : "down" + : "actions", + }; + say(labels.moved(name(item), i + by + 1)); + onMove?.(i, by); + }; + const remove = (i: number) => { + const item = items[i] as ReferenceListItem; + const next = items[i + 1] ?? items[i - 1]; + focus.current = next ? { id: next.id, at: "actions" } : "after"; + say(labels.removed(name(item))); + onRemove?.(i); + }; return ( -
    - {items.map((item, i) => { - const name = item.title ?? item.id; - const status = item.status; - const shown = status === "published"; - const first = i === 0; - const last = i === items.length - 1; - return ( -
  • - {/* The actions stay on the title's line; badges wrap under the title. */} - - - {status === undefined ? ( - - - - ) : status === "missing" ? ( - - {item.id} - - ) : item.href ? ( - - {name} - - ) : ( - - {name} - - )} - - {item.meta && status !== "missing" && ( - - {item.meta} - - )} - {status !== undefined && status !== "published" && ( - - {status === "missing" ? labels.notFound : labels[status]} - - )} - {status !== undefined && !shown && ( - - {status === "missing" ? labels.notFoundHint : labels.notShown} - - )} - +
    + {items.length > 0 && ( +
      + {items.map((item, i) => ( + move(i, by)} + onRemove={() => remove(i)} + labels={labels} + /> + ))} +
    + )} + + {said} + +
    + ); +} + +/** One target: its title, collection and language, state, and (unless read-only) its actions. */ +function ReferenceRow({ + item, + first, + last, + sortable, + readOnly, + onMove, + onRemove, + labels, +}: { + item: ReferenceListItem; + first: boolean; + last: boolean; + sortable: boolean; + readOnly: boolean; + onMove: (by: -1 | 1) => void; + onRemove: () => void; + labels: ReferenceListLabels; +}) { + const name = item.title ?? item.id; + const status = item.status; + const shown = status === "published"; + return ( +
  • + {/* The actions stay on the title's line; badges wrap under the title. */} + + + {status === undefined ? ( + + + + ) : status === "missing" ? ( + + {item.id} + + ) : item.href ? ( + + {name} + + ) : ( + + {name} + + )} + + {item.meta && status !== "missing" && ( + + {item.meta} + + )} + {status !== undefined && status !== "published" && ( + + {status === "missing" ? labels.notFound : labels[status]} + + )} + {status !== undefined && !shown && ( + + {status === "missing" ? labels.notFoundHint : labels.notShown} + + )} + + + {!readOnly && ( + + {sortable && ( + +
  • - ); - })} -
+ )} + + } + aria-label={labels.actions(name)} + data-reference-actions="" + /> + } + /> + + {item.href && status !== "missing" && ( + + {labels.open} + + )} + {sortable && ( + <> + onMove(-1)}> + {labels.menuMoveUp} + + onMove(1)}> + {labels.menuMoveDown} + + + + )} + onRemove()}> + {labels.remove} + + + + + )} + + ); } diff --git a/packages/ui/src/blocks/RepeaterField.tsx b/packages/ui/src/blocks/RepeaterField.tsx index 45409763..360574ab 100644 --- a/packages/ui/src/blocks/RepeaterField.tsx +++ b/packages/ui/src/blocks/RepeaterField.tsx @@ -12,7 +12,7 @@ import { import { type ReactNode, useId } from "react"; import { DirectionalIcon } from "./DirectionalIcon.tsx"; import type { FieldValues } from "./entry-fields-model.ts"; -import { useRepeater, withSub } from "./repeater-state.ts"; +import { useRepeater } from "./repeater-state.ts"; import { SectionEmpty } from "./SectionEmpty.tsx"; /** Every word of the block (no English fallback). Item numbers are 1-based. */ @@ -73,10 +73,16 @@ export interface RepeaterFieldProps { /** At most this many items. */ max: number; readOnly?: boolean; - /** Problems by item index (0-based); a new map opens the items it names. */ + /** + * Problems by item index (0-based) when the map arrives. A new map opens the items it names; + * from then on the problems follow their items through moves and removals. + */ itemErrors?: ReadonlyMap | undefined; - /** After "Remove": the host offers `undo` (Studio: a toast), which puts the item back. */ - onRemoved?: (removed: { index: number; legend: string; undo: () => void }) => void; + /** + * After "Remove": the host offers `undo` (Studio: a toast). It puts the item back and answers + * true, or does nothing and answers false when the list is gone or full. + */ + onRemoved?: (removed: { index: number; legend: string; undo: () => boolean }) => void; labels: RepeaterFieldLabels; } @@ -92,11 +98,11 @@ export interface RepeaterFieldProps { export function RepeaterField(props: RepeaterFieldProps) { const { items, max, labels, readOnly = false } = props; const id = useId(); - const { root, keys, latest, open, said, toggle, add, move, duplicate, remove, reveal } = + const { root, keys, open, said, errors, toggle, add, move, duplicate, remove, reveal, setSub } = useRepeater(props); - const collapsedInvalid = [...(props.itemErrors?.keys() ?? [])] - .filter((i) => i < items.length && !open.has(keys.current[i] as string)) - .sort((a, b) => a - b); + const collapsedInvalid = keys.current + .map((key, i) => ({ key, i })) + .filter(({ key }) => errors.has(key) && !open.has(key)); const full = items.length >= max; return (
(props: RepeaterFieldPro {labels.invalidItems(collapsedInvalid.length)} - {collapsedInvalid.map((i) => ( - ))} @@ -148,7 +154,7 @@ export function RepeaterField(props: RepeaterFieldPro open={open.has(key)} onOpen={(on) => toggle(key, on)} legend={props.legend(i, item)} - errors={props.itemErrors?.get(i)} + errors={errors.get(key)} readOnly={readOnly} full={full} labels={labels} @@ -163,12 +169,8 @@ export function RepeaterField(props: RepeaterFieldPro item, index: i, value: item[sub.key], - onChange: (v) => { - const next = [...latest.current]; - next[i] = withSub(next[i] ?? {}, sub.key, v); - props.onChange(next); - }, - error: props.itemErrors?.get(i)?.subs.get(sub.key), + onChange: (v) => setSub(key, sub.key, v), + error: errors.get(key)?.subs.get(sub.key), readOnly, })}
diff --git a/packages/ui/src/blocks/announcer.ts b/packages/ui/src/blocks/announcer.ts new file mode 100644 index 00000000..40662e77 --- /dev/null +++ b/packages/ui/src/blocks/announcer.ts @@ -0,0 +1,19 @@ +/** + * A polite live region's words that are announced every time, the same message twice included: + * the region is emptied first and the message set a moment later, so screen readers see a change. + */ +import { useCallback, useEffect, useRef, useState } from "react"; + +const SETTLE_MS = 50; + +export function useAnnouncer(): readonly [string, (message: string) => void] { + const [text, setText] = useState(""); + const timer = useRef | undefined>(undefined); + useEffect(() => () => clearTimeout(timer.current), []); + const say = useCallback((message: string) => { + clearTimeout(timer.current); + setText(""); + timer.current = setTimeout(() => setText(message), SETTLE_MS); + }, []); + return [text, say] as const; +} diff --git a/packages/ui/src/blocks/entry-fields-model.ts b/packages/ui/src/blocks/entry-fields-model.ts index a6824955..05e4ccbf 100644 --- a/packages/ui/src/blocks/entry-fields-model.ts +++ b/packages/ui/src/blocks/entry-fields-model.ts @@ -199,3 +199,58 @@ export function repeaterErrors( } return out; } + +/** + * A repeater's items as the form shows them while a sub-field rename runs (RFC-0045 §7, `sub` with + * `fromKey`): an item not rewritten yet holds the value under the old key, shown under the new one. + * Writing these items back leaves only the new key, as `writeField` does for a field. + */ +export function shownItems(def: Repeater, value: unknown): FieldValues[] { + const items = repeaterItems(value); + const m = def.migration; + if (m?.sub === undefined || !m.fromKey) return items; + const { sub, fromKey } = m; + return items.map((item) => { + if (!Object.hasOwn(item, fromKey)) return item; + const { [fromKey]: old, ...rest } = item; + return Object.hasOwn(rest, sub) ? rest : { ...rest, [sub]: old }; + }); +} + +const isString = (v: unknown) => typeof v === "string"; + +/** True when a value has the shape of its type (not its limits): what a control can show and edit. */ +export function fitsShape(def: FieldDefinition | SubFieldDefinition, v: unknown): boolean { + const several = "multiple" in def && def.multiple === true; + switch (def.type) { + case "number": + return typeof v === "number"; + case "boolean": + return typeof v === "boolean"; + case "datetime": + return instantOf(v) !== undefined; + case "select": + case "reference": + return several ? Array.isArray(v) && v.every(isString) : isString(v); + case "repeater": + return Array.isArray(v) && v.every(isRecord); + default: + return isString(v); + } +} + +/** + * True for a stored value a running type change has not converted yet (RFC-0041 §6, RFC-0045 §7): + * the definition (or `sub`'s) carries `fromType` and the value is not of the new type's shape. The + * form shows it read-only rather than letting an edit overwrite it; phase B converts it. + */ +export function unconverted( + repeaterOrField: FieldDefinition, + def: FieldDefinition | SubFieldDefinition, + v: unknown, +): boolean { + const m = repeaterOrField.migration; + if (m?.fromType === undefined || !hasValue(v)) return false; + const marked = def === repeaterOrField ? m.sub === undefined : m.sub === def.key; + return marked && !fitsShape(def, v); +} diff --git a/packages/ui/src/blocks/repeater-state.ts b/packages/ui/src/blocks/repeater-state.ts index a340a888..17d3dcfe 100644 --- a/packages/ui/src/blocks/repeater-state.ts +++ b/packages/ui/src/blocks/repeater-state.ts @@ -1,8 +1,11 @@ /** - * The state of `RepeaterField` (RFC-0045 §5): stable item keys, which items are open, focus after - * add, move, remove and undo, and the live region's words. No words of its own. + * The state of `RepeaterField` (RFC-0045 §5): stable item keys, which items are open, problems by + * item key, focus after add, move, remove and undo, and the live region's words. No words of its + * own. Writes go through the latest `onChange` and the latest items, so an undo that runs seconds + * later never brings back what was there when the item was removed. */ import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useAnnouncer } from "./announcer.ts"; import { type FieldValues, hasValue } from "./entry-fields-model.ts"; import type { RepeaterFieldLabels, RepeaterItemErrors } from "./RepeaterField.tsx"; @@ -34,10 +37,31 @@ export interface RepeaterStateProps { onChange: (items: FieldValues[]) => void; legend: (index: number, item: FieldValues) => string; itemErrors?: ReadonlyMap | undefined; - onRemoved?: (removed: { index: number; legend: string; undo: () => void }) => void; + onRemoved?: (removed: { index: number; legend: string; undo: () => boolean }) => void; + max: number; labels: RepeaterFieldLabels; } +/** Problems by item index, as they arrived, moved onto the keys of the items they named then. */ +function useErrorsByKey( + itemErrors: RepeaterStateProps["itemErrors"], + keys: { current: string[] }, +): ReadonlyMap { + const keyed = useRef<{ source: unknown; byKey: Map }>({ + source: undefined, + byKey: new Map(), + }); + if (keyed.current.source !== itemErrors) { + const byKey = new Map(); + for (const [i, errors] of itemErrors ?? []) { + const key = keys.current[i]; + if (key) byKey.set(key, errors); + } + keyed.current = { source: itemErrors, byKey }; + } + return keyed.current.byKey; +} + /** Keys, open items, focus and announcements, and the operations that keep them in step. */ export function useRepeater(props: RepeaterStateProps) { const { items, labels } = props; @@ -45,23 +69,27 @@ export function useRepeater(props: RepeaterStateProps) { const { keys, fresh } = useItemKeys(items.length); const latest = useRef(items); latest.current = items; + const onChange = useRef(props.onChange); + onChange.current = props.onChange; + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); const [open, setOpen] = useState>(() => items.length <= 3 ? new Set(keys.current) : new Set(), ); - const [said, say] = useState(""); + const [said, say] = useAnnouncer(); const focus = useRef(null); + const errors = useErrorsByKey(props.itemErrors, keys); + // A new error map opens the items it names. useEffect(() => { - if (!props.itemErrors?.size) return; - setOpen((was) => { - const next = new Set(was); - for (const i of props.itemErrors?.keys() ?? []) { - const key = keys.current[i]; - if (key) next.add(key); - } - return next; - }); - }, [props.itemErrors, keys]); + if (!errors.size) return; + setOpen((was) => new Set([...was, ...errors.keys()])); + }, [errors]); useLayoutEffect(() => { const want = focus.current; @@ -85,7 +113,15 @@ export function useRepeater(props: RepeaterStateProps) { const commit = (next: FieldValues[], nextKeys: string[]) => { keys.current = nextKeys; - props.onChange(next); + onChange.current(next); + }; + /** One item's sub-value, written over the latest items. */ + const setSub = (key: string, sub: string, v: unknown) => { + const i = keys.current.indexOf(key); + if (i < 0) return; + const next = [...latest.current]; + next[i] = withSub(next[i] ?? {}, sub, v); + onChange.current(next); }; const toggle = (key: string, on: boolean) => setOpen((was) => { @@ -136,23 +172,25 @@ export function useRepeater(props: RepeaterStateProps) { props.onRemoved?.({ index: i, legend, + // False when it can't: the list is gone (another entry, another screen) or full. undo: () => { const now = latest.current; + if (!mounted.current || now.length >= props.max || keys.current.includes(key)) return false; const at = Math.min(i, now.length); focus.current = { key, at: "toggle" }; commit( [...now.slice(0, at), removed, ...now.slice(at)], [...keys.current.slice(0, at), key, ...keys.current.slice(at)], ); + return true; }, }); }; /** Opens an item and focuses its first control (the list of collapsed problems). */ - const reveal = (i: number) => { - const key = keys.current[i] as string; + const reveal = (key: string) => { toggle(key, true); focus.current = { key, at: "first" }; }; - return { root, keys, latest, open, said, toggle, add, move, duplicate, remove, reveal }; + return { root, keys, open, said, errors, toggle, add, move, duplicate, remove, reveal, setSub }; } diff --git a/packages/ui/test/entry-fields-form.test.tsx b/packages/ui/test/entry-fields-form.test.tsx index d6396719..80343b69 100644 --- a/packages/ui/test/entry-fields-form.test.tsx +++ b/packages/ui/test/entry-fields-form.test.tsx @@ -413,3 +413,77 @@ describe("EntryFieldsForm: references and repeaters (RFC-0045 §5)", () => { ); }); }); + +const L = (label: string) => ({ en: { label } }); + +describe("EntryFieldsForm during a running field operation (RFC-0045 §7)", () => { + const base = { labels: { en: { label: "Speakers" } }, localized: false } as const; + const render2 = (defs: FieldDefinition[], value: Record, onChange = vi.fn()) => + render( + z} + labels={labels} + />, + ); + + it("shows a sub-field rename's old key under the new one, and writes only the new key", () => { + const onChange = vi.fn(); + render2( + [ + { + ...base, + key: "speakers", + type: "repeater", + migration: { id: "snap_1", sub: "talk", fromKey: "title" }, + fields: [{ key: "talk", type: "text", labels: { en: { label: "Talk" } } }], + }, + ], + { speakers: [{ title: "Engines" }] }, + onChange, + ); + const talk = screen.getByRole("textbox", { name: "Talk" }); + expect(talk).toHaveValue("Engines"); + expect(screen.getByRole("group", { name: "Item 1: Engines" })).toBeInTheDocument(); + fireEvent.change(talk, { target: { value: "Engines, again" } }); + expect(onChange).toHaveBeenLastCalledWith({ speakers: [{ talk: "Engines, again" }] }); + }); + + it("shows a value a type change has not converted read-only, as stored", () => { + const onChange = vi.fn(); + render2( + [ + { + ...base, + key: "speakers", + type: "repeater", + migration: { id: "snap_1", fromType: "reference" }, + fields: [ + { key: "person", type: "reference", labels: L("Person"), collections: ["people"] }, + ], + }, + { + key: "hosts", + type: "repeater", + labels: L("Hosts"), + localized: false, + migration: { id: "snap_2", sub: "who", fromType: "text" }, + fields: [{ key: "who", type: "number", labels: L("Who") }], + }, + ], + { speakers: "entry_hall0001", hosts: [{ who: "Ada" }] }, + onChange, + ); + const stored = screen.getByRole("textbox", { name: "Speakers" }); + expect(stored).toBeDisabled(); + expect(stored).toHaveValue('"entry_hall0001"'); + expect(within(field("speakers")).getByText(/Being changed/)).toBeInTheDocument(); + expect(within(field("hosts")).getByRole("textbox", { name: "Who" })).toBeDisabled(); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/test/entry-picker.test.tsx b/packages/ui/test/entry-picker.test.tsx index 3f35023a..9abf34b3 100644 --- a/packages/ui/test/entry-picker.test.tsx +++ b/packages/ui/test/entry-picker.test.tsx @@ -1,6 +1,7 @@ /** RFC-0045 §5: the entry picker, the chosen targets and "Used by". */ import { Button } from "@cloudflare/kumo"; import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { EntryPicker, type EntryPickerItem } from "../src/blocks/EntryPicker.tsx"; import { ReferenceList } from "../src/blocks/ReferenceList.tsx"; @@ -39,6 +40,43 @@ function open(props: Partial[0]> = {}) { const box = () => screen.getByRole("combobox", { name: "Search entries" }); +function Full() { + const [chosen, setChosen] = useState([]); + return ( + Add speakers} + search={async () => ({ items: [HALL, ROOM] })} + multiple + remaining={2 - chosen.length} + disabled={chosen.length >= 2} + onPick={(items) => setChosen((c) => [...c, ...items.map((i) => i.id)])} + labels={pickerLabels} + /> + ); +} + +function List({ after }: { after: HTMLElement }) { + const [items, setItems] = useState([ + { id: "entry_ada0001", title: "Ada", status: "published" as const }, + { id: "entry_grace01", title: "Grace", status: "published" as const }, + ]); + return ( + { + const next = [...items]; + [next[i], next[i + by]] = [next[i + by] as (typeof items)[0], next[i] as (typeof items)[0]]; + setItems(next); + }} + onRemove={(i) => setItems(items.filter((_, n) => n !== i))} + focusAfterLast={() => after} + labels={referenceLabels} + /> + ); +} + afterEach(cleanup); describe("EntryPicker", () => { @@ -109,6 +147,34 @@ describe("EntryPicker", () => { }); }); +describe("EntryPicker: failures and a full field", () => { + it("says a failed search failed, politely, instead of 'No entries found'", async () => { + open({ search: async () => Promise.reject(new Error("500")) }); + await waitFor(() => + expect(document.querySelector("[data-entry-picker-status]")).toHaveTextContent( + "The search failed", + ), + ); + expect(screen.queryByText("No entries found")).toBeNull(); + }); + + it("keeps focus on the trigger when a pick fills the field, and opens nothing then", async () => { + render(); + const trigger = screen.getByRole("button", { name: "Add speakers" }); + fireEvent.click(trigger); + await screen.findByRole("option", { name: /Room B/ }); + fireEvent.keyDown(box(), { key: "Enter" }); + fireEvent.keyDown(box(), { key: "ArrowDown" }); + fireEvent.keyDown(box(), { key: "Enter" }); + fireEvent.click(screen.getByRole("button", { name: "Add 2" })); + await waitFor(() => expect(screen.queryByRole("combobox")).toBeNull()); + await waitFor(() => expect(trigger).toHaveFocus()); + expect(trigger).toHaveAttribute("aria-disabled", "true"); + fireEvent.click(trigger); + expect(screen.queryByRole("combobox")).toBeNull(); + }); +}); + describe("ReferenceList", () => { it("shows each target's state and moves and removes through buttons and the menu", async () => { const onMove = vi.fn(); @@ -143,6 +209,30 @@ describe("ReferenceList", () => { expect(onRemove).toHaveBeenCalledWith(1); }); + it("keeps focus on a moved row and moves it to the next row, or out, after a remove", async () => { + const after = document.createElement("button"); + document.body.append(after); + render(); + const down = screen.getByRole("button", { name: "Move Ada down" }); + down.focus(); + fireEvent.click(down); + // Ada is last now: its down button is disabled, so focus goes to its up button. + await waitFor(() => expect(screen.getByRole("button", { name: "Move Ada up" })).toHaveFocus()); + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent("Ada moved to position 2"), + ); + fireEvent.click(screen.getByRole("button", { name: "Actions for Grace" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "Remove" })); + await waitFor(() => + expect(screen.getByRole("button", { name: "Actions for Ada" })).toHaveFocus(), + ); + await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("Grace removed")); + fireEvent.click(screen.getByRole("button", { name: "Actions for Ada" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "Remove" })); + await waitFor(() => expect(after).toHaveFocus()); + after.remove(); + }); + it("read-only shows no actions", () => { render( `${n} items`, repeater: repeaterLabels, itemLegend: (i, name) => (name ? `Item ${i}: ${name}` : `Item ${i}`), @@ -63,6 +64,8 @@ export const referenceLabels: ReferenceListLabels = { menuMoveDown: "Move down", remove: "Remove", loading: "Loading", + moved: (t, n) => `${t} moved to position ${n}`, + removed: (t) => `${t} removed`, }; export const pickerLabels: EntryPickerLabels = { diff --git a/packages/ui/test/repeater-field.test.tsx b/packages/ui/test/repeater-field.test.tsx index cb9a4aa4..cbeb52c1 100644 --- a/packages/ui/test/repeater-field.test.tsx +++ b/packages/ui/test/repeater-field.test.tsx @@ -18,7 +18,7 @@ function Harness({ initial: FieldValues[]; max?: number; onChange?: (items: FieldValues[]) => void; - onRemoved?: (r: { index: number; legend: string; undo: () => void }) => void; + onRemoved?: (r: { index: number; legend: string; undo: () => boolean }) => void; itemErrors?: ReadonlyMap; }) { const [items, setItems] = useState(initial); @@ -86,7 +86,9 @@ describe("RepeaterField", () => { render(); fireEvent.click(screen.getByRole("button", { name: "Move item 3 up" })); expect(legends()).toEqual(["Speaker 1: Ada", "Speaker 2: Linus", "Speaker 3: Grace"]); - expect(screen.getByRole("status")).toHaveTextContent("Item 3 moved to position 2"); + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent("Item 3 moved to position 2"), + ); await waitFor(() => expect(screen.getByRole("button", { name: "Move item 2 up" })).toHaveFocus(), ); @@ -116,7 +118,9 @@ describe("RepeaterField", () => { expect(onRemoved).toHaveBeenCalledWith( expect.objectContaining({ index: 1, legend: "Speaker 2: Grace" }), ); - act(() => onRemoved.mock.lastCall?.[0].undo()); + act(() => { + expect(onRemoved.mock.lastCall?.[0].undo()).toBe(true); + }); expect(legends()).toEqual(["Speaker 1: Ada", "Speaker 2: Grace", "Speaker 3: Linus"]); await waitFor(() => expect( @@ -160,4 +164,65 @@ describe("RepeaterField", () => { fireEvent.click(back); await waitFor(() => expect(screen.getByLabelText("name 3")).toHaveFocus()); }); + it("undo writes over the latest items and handler, and refuses a full list", async () => { + const onRemoved = vi.fn(); + const onChange = vi.fn(); + render( + , + ); + fireEvent.click(await menuItem(1, "Remove")); + // An edit after the removal is kept by the undo. + fireEvent.change(screen.getByLabelText("role 1"), { target: { value: "Admiral" } }); + act(() => { + expect(onRemoved.mock.lastCall?.[0].undo()).toBe(true); + }); + expect(onChange).toHaveBeenLastCalledWith([ + { name: "Ada" }, + { name: "Grace", role: "Admiral" }, + ]); + // Full again: a second undo of the same removal, or another one, does nothing. + fireEvent.click(await menuItem(2, "Remove")); + fireEvent.click(screen.getByRole("button", { name: "Add item" })); + act(() => { + expect(onRemoved.mock.lastCall?.[0].undo()).toBe(false); + }); + expect(items()).toHaveLength(2); + }); + + it("an undo after the list is gone does nothing", async () => { + const onRemoved = vi.fn(); + const { unmount } = render(); + fireEvent.click(await menuItem(1, "Remove")); + unmount(); + expect(onRemoved.mock.lastCall?.[0].undo()).toBe(false); + }); + + it("problems follow their item when an earlier one is removed", async () => { + const errors = new Map([[1, { subs: new Map([["role", "Needed to publish"]]) }]]); + render(); + const grace = () => screen.getByRole("group", { name: /Grace/ }); + expect(within(grace()).getByText("Needed to publish")).toBeInTheDocument(); + fireEvent.click(await menuItem(1, "Remove")); + expect(within(grace()).getByText("Needed to publish")).toBeInTheDocument(); + expect(grace()).toHaveAttribute("aria-invalid", "true"); + }); + + it("announces the same message twice", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Move item 2 down" })); + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent("Item 2 moved to position 3"), + ); + fireEvent.click(screen.getByRole("button", { name: "Move item 2 down" })); + // The region empties first, so the repeat is a change screen readers announce. + expect(screen.getByRole("status")).toHaveTextContent(""); + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent("Item 2 moved to position 3"), + ); + }); }); From 98a7e3b38d16dc66a52d8238c3829f81572cd030 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:42:08 +0300 Subject: [PATCH 3/4] fix(studio,ui): RFC-0045 step 6a review round 2 (PR #219) - Item issues after a save that left empty items out are mapped back onto the draft's items (sentIndexes, draftPath) before the error map is built, so a problem never lands on an empty item. - A read-only repeater shows every item open (its toggles sit in the editor's disabled fieldset). - "Used by" moved to the reach cards (EntryUsedByCard, beside "In menus"), outside the read-only fieldset, so "Show more" works when the person can't edit the article. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .../studio/src/components/EntryFieldCards.tsx | 54 ++++++----- .../article-editor/ArticleAside.tsx | 7 +- .../src/components/fields/EntryFieldsCard.tsx | 12 ++- apps/studio/src/lib/empty-items.ts | 27 ++++++ apps/studio/test/fields-references.test.tsx | 95 ++++++++++++------- docs/design/DESIGN.md | 2 +- ...ctions-stage-4-references-and-repeaters.md | 5 + packages/ui/src/blocks/RepeaterField.tsx | 8 +- packages/ui/test/entry-fields-form.test.tsx | 31 ++++++ 9 files changed, 176 insertions(+), 65 deletions(-) diff --git a/apps/studio/src/components/EntryFieldCards.tsx b/apps/studio/src/components/EntryFieldCards.tsx index 162ac442..4bdcecc6 100644 --- a/apps/studio/src/components/EntryFieldCards.tsx +++ b/apps/studio/src/components/EntryFieldCards.tsx @@ -1,5 +1,7 @@ import type { ContentEntry, Site } from "@cloudhal/core-contracts"; +import { useQuery } from "@tanstack/react-query"; import { lazy, Suspense } from "react"; +import { api } from "../lib/api.ts"; // RFC-0041 §7: the typed fields form loads only for collections that define fields. const EntryFieldsCard = lazy(() => @@ -23,9 +25,8 @@ function referenced(site: Site, collection: string): boolean { } /** - * The entry editor's field cards, each a lazy chunk of its own: "Fields" for a collection with - * definitions (RFC-0041), and "Used by" when a reference can point at this collection (RFC-0045). - * A site without either loads neither. + * The entry editor's "Fields" card (RFC-0041), a lazy chunk of its own, for a collection with + * definitions; a collection without any loads nothing. */ export function EntryFieldCards({ siteId, @@ -42,26 +43,33 @@ export function EntryFieldCards({ error: unknown; disabled: boolean; }) { - if (!site) return null; + if (!site?.collections?.[entry.collection]?.fields) return null; return ( - <> - {site.collections?.[entry.collection]?.fields && ( - - - - )} - {referenced(site, entry.collection) && ( - - - - )} - + + + + ); +} + +/** + * "Used by" (RFC-0045 §5), a lazy chunk of its own, shown only when some field of the site can + * reference the entry's collection. It sits with the editor's reach cards, outside the read-only + * fieldset, so "Show more" works on an article the person can't edit. The site comes from the + * editor's cached query. + */ +export function EntryUsedByCard({ siteId, entry }: { siteId: string; entry: ContentEntry }) { + const site = useQuery({ queryKey: ["site", siteId], queryFn: () => api.sites.get(siteId) }); + if (!site.data || !referenced(site.data, entry.collection)) return null; + return ( + + + ); } diff --git a/apps/studio/src/components/article-editor/ArticleAside.tsx b/apps/studio/src/components/article-editor/ArticleAside.tsx index 895b0ac9..29668844 100644 --- a/apps/studio/src/components/article-editor/ArticleAside.tsx +++ b/apps/studio/src/components/article-editor/ArticleAside.tsx @@ -1,6 +1,10 @@ -/** The article editor's cards about where the article reaches: readers, comments, menus, languages. */ +/** + * The article editor's cards about where the article reaches: readers, comments, menus, the + * entries that reference it (RFC-0045 "Used by"), languages. + */ import type { ContentEntry } from "@cloudhal/core-contracts"; import { ArticleCommentsCard } from "../ArticleCommentsCard.tsx"; +import { EntryUsedByCard } from "../EntryFieldCards.tsx"; import { UsedInMenusCard } from "../Menus.tsx"; import { ReadersCard } from "../Readers.tsx"; import { TranslationsCard } from "../Translations.tsx"; @@ -19,6 +23,7 @@ export function ArticleReachCards({ + ); diff --git a/apps/studio/src/components/fields/EntryFieldsCard.tsx b/apps/studio/src/components/fields/EntryFieldsCard.tsx index 7e3807a6..3e2732ff 100644 --- a/apps/studio/src/components/fields/EntryFieldsCard.tsx +++ b/apps/studio/src/components/fields/EntryFieldsCard.tsx @@ -9,9 +9,10 @@ import { Text, } from "@cloudhal/ui"; import { useQuery } from "@tanstack/react-query"; -import { useMemo } from "react"; +import { useMemo, useRef } from "react"; import { type TFunction, useI18n } from "../../i18n/index.tsx"; import { api } from "../../lib/api.ts"; +import { draftPath, sentIndexes } from "../../lib/empty-items.ts"; import { entryHref } from "../../lib/entry-href.ts"; import { chooseMember, issuesByPath, referenceIdsIn } from "../../lib/field-references.ts"; import { fieldIssues } from "../../lib/fields.ts"; @@ -156,10 +157,17 @@ export function EntryFieldsCard({ const definitions = site.collections?.[entry.collection]?.fields ?? []; const original = useOriginal(siteId, entry); const labels = useFormLabels(t, original.data?.locale, locale); + // The draft as it was when the error arrived: a save leaves empty items out, so the server's item + // indices are mapped back onto the draft's once, then follow their items (RepeaterField). + const drafted = useRef({ entry, site }); + drafted.current = { entry, site }; const errors = useMemo(() => { + const sent = sentIndexes(drafted.current.entry, drafted.current.site); const out: Record = {}; for (const [path, issue] of Object.entries(issuesByPath(fieldIssues(error)))) - out[path] = isIssueCode(issue.code) ? t(`fields.issues.${issue.code}`) : issue.message; + out[draftPath(path, sent)] = isIssueCode(issue.code) + ? t(`fields.issues.${issue.code}`) + : issue.message; return out; }, [error, t]); // RFC-0045: the members of every target the entry and its original name. diff --git a/apps/studio/src/lib/empty-items.ts b/apps/studio/src/lib/empty-items.ts index 0b118f2e..976b317b 100644 --- a/apps/studio/src/lib/empty-items.ts +++ b/apps/studio/src/lib/empty-items.ts @@ -13,6 +13,7 @@ const isItem = (v: unknown): v is Record => /** The entry's fields without empty repeater items (the same entry when there are none). */ export function withoutEmptyItems(entry: ContentEntry, site: Site | undefined): ContentEntry { + // Keep in step with `sentIndexes`, which maps the server's item indices back onto the draft. const defs = site?.collections?.[entry.collection]?.fields ?? []; const fields = entry.fields; if (!fields) return entry; @@ -28,3 +29,29 @@ export function withoutEmptyItems(entry: ContentEntry, site: Site | undefined): } return next ? { ...entry, fields: next } : entry; } + +/** + * Per repeater whose save leaves items out, the draft index of each item it sends: the server's + * issue paths count items of what was sent, the editor's of the draft. + */ +export function sentIndexes(entry: ContentEntry, site: Site | undefined): Map { + const out = new Map(); + for (const def of site?.collections?.[entry.collection]?.fields ?? []) { + const v = entry.fields?.[def.key]; + if (def.type !== "repeater" || !Array.isArray(v)) continue; + const sent = v.flatMap((item, i) => + isItem(item) && Object.keys(item).length === 0 ? [] : [i], + ); + if (sent.length !== v.length) out.set(def.key, sent); + } + return out; +} + +/** An issue path under `fields` of what was sent (`speakers/1/role`) as the draft's (`speakers/2/role`). */ +export function draftPath(path: string, sent: ReadonlyMap): string { + const [key = "", index, ...rest] = path.split("/"); + const map = sent.get(key); + const i = Number(index); + if (!map || !Number.isInteger(i) || map[i] === undefined) return path; + return [key, String(map[i]), ...rest].join("/"); +} diff --git a/apps/studio/test/fields-references.test.tsx b/apps/studio/test/fields-references.test.tsx index f912c668..5ac65aa1 100644 --- a/apps/studio/test/fields-references.test.tsx +++ b/apps/studio/test/fields-references.test.tsx @@ -8,7 +8,7 @@ import { Button } from "@cloudhal/ui"; import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { EntryFieldCards } from "../src/components/EntryFieldCards.tsx"; +import { EntryUsedByCard } from "../src/components/EntryFieldCards.tsx"; import { EntryFieldsCard } from "../src/components/fields/EntryFieldsCard.tsx"; import { ApiRequestError } from "../src/lib/api/http.ts"; import { withoutEmptyItems } from "../src/lib/empty-items.ts"; @@ -227,6 +227,31 @@ function EditorHarness({ ); } +describe("Entry editor › item issues after empty items are left out", () => { + it("maps the server's item index back onto the draft", async () => { + serve(groups({ entry_hall0001: [card("entry_hall0001", "Main hall")] })); + const entry = { + ...ENTRY, + fields: { speakers: [{ name: "Ada", role: "Host" }, {}, { name: "Grace" }] }, + } as unknown as ContentEntry; + // What was sent was [Ada, Grace]: the server names Grace as item 1. + const error = new ApiRequestError(422, { + error: "required_field", + message: "needed", + details: { + issues: [{ path: "fields/speakers/1/role", code: "required_field", message: "needed" }], + }, + }); + render(wrap()); + const grace = await screen.findByRole("group", { name: "Item 3: Grace" }); + expect( + within(grace).getByText("Needed to publish. Add a value, or keep the entry as a draft."), + ).toBeInTheDocument(); + const empty = screen.getByRole("group", { name: "Item 2" }); + expect(empty).not.toHaveAttribute("aria-invalid"); + }); +}); + describe("Entry editor › repeater undo (review fixes)", () => { it("keeps every edit made after the removal", async () => { serve(groups({ entry_hall0001: [card("entry_hall0001", "Main hall")] })); @@ -319,24 +344,35 @@ describe("Saving drops empty repeater items (RFC-0045 §1)", () => { }); }); +const siteRoute = (site: Site) => on("GET", "/api/sites/site_1", { status: 200, body: site }); +const referrer = (id: string, title: string) => ({ + ...card(id, title), + collection: "events", + field: "venue", + shownBy: [] as unknown[], +}); + describe("Entry editor › Used by", () => { - it("lists referrers where some field can reference the collection", async () => { - serve( - on("GET", "/api/sites/site_1/entries/entry_hall0001/references", { + it("lists referrers where some field can reference the collection, and pages", async () => { + const fetch = serve(siteRoute(SITE), (url) => { + if (!url.startsWith("/api/sites/site_1/entries/entry_hall0001/references")) return undefined; + const second = url.includes("offset=20"); + return { status: 200, - body: { - groupId: "entry_hall0001", - referrers: [ - { - ...card("entry_event001", "Launch"), - collection: "events", - field: "venue", - shownBy: [{ ...card("entry_event0tr", "Lansman", "published", "tr") }], + body: second + ? { groupId: "entry_hall0001", referrers: [referrer("entry_event021", "Encore")] } + : { + groupId: "entry_hall0001", + next: 20, + referrers: [ + { + ...referrer("entry_event001", "Launch"), + shownBy: [card("entry_event0tr", "Lansman", "published", "tr")], + }, + ], }, - ], - }, - }), - ); + }; + }); const hall = { ...ENTRY, id: "entry_hall0001", @@ -344,18 +380,7 @@ describe("Entry editor › Used by", () => { title: "Main hall", fields: {}, } as ContentEntry; - render( - wrap( - {}} - error={undefined} - disabled={false} - />, - ), - ); + render(wrap()); const table = await screen.findByRole("table", { name: "Entries that reference this one" }); expect(await within(table).findByRole("link", { name: "Launch" })).toHaveAttribute( "href", @@ -363,22 +388,22 @@ describe("Entry editor › Used by", () => { ); expect(within(table).getByText(/· English · Venue$/)).toBeInTheDocument(); expect(within(table).getByText("Also shown in Turkish")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Show more" })); + expect(await within(table).findByRole("link", { name: "Encore" })).toBeInTheDocument(); + expect(fetch.mock.calls.some(([u]) => String(u).includes("offset=20"))).toBe(true); }); - it("shows nothing for a collection no field can reference", () => { - serve(); + it("shows nothing for a collection no field can reference", async () => { + const fetch = serve(siteRoute({ ...SITE, collections: { places: {} } } as unknown as Site)); const { container } = render( wrap( - {}} - error={undefined} - disabled={false} />, ), ); + await waitFor(() => expect(fetch).toHaveBeenCalled()); expect(container).toBeEmptyDOMElement(); }); }); diff --git a/docs/design/DESIGN.md b/docs/design/DESIGN.md index 876d089f..319503b0 100644 --- a/docs/design/DESIGN.md +++ b/docs/design/DESIGN.md @@ -479,7 +479,7 @@ Site › Settings gets an **Appearance** section after Domains (`components/Appe ### 2ah. Typed custom fields (RFC-0041 §7, ADR-0043) Words in the lazy `fields` namespace (loaded with the entry editor and collection settings routes). The UI (`components/fields/`, `lib/fields.ts`, `lib/field-form.ts`, the `EntryFieldsForm` block and `zoned-time.ts`, `@cloudhal/core-ts/fields`) is `LAZY_ONLY` in `bundle-budget.ts` and loads only for a collection that has definitions. - **Entry editor › Fields** (`EntryFieldsCard`, a `SectionCard` after "Article", only when the collection defines fields): the `EntryFieldsForm` block, saved with the entry (Save and the status actions send `fields`). One control per type: text → `Input` (`InputArea` when `multiline`; `maxLength` on the control), rich text → the body editor (`BodyEditor size="sm"`, lazy Tiptap) under a `Label`, number → `Input type=number` (min/max/step from the definition), boolean → `Switch`, date → `DateTimeField dateOnly`, datetime → `DateTimeField` in the value's own zone (the site's for a new value; stored as `{ at, timeZone }`), URL → `Input type=url`, image → `FieldImage` (thumbnail, title and size in an `InsetPanel row`, "Choose image" / "Change" / "Remove", the editor's `AssetPickerDialog`), select → `Select` with a "No value" choice, or a `Checkbox.Group` when `multiple`. Dates get a ghost "Clear". Labels and help come from the definition in the Studio language (`fieldLabel` / `fieldHelp` / `optionLabel`, then the site's default language); the description starts with an outline `Badge` "Needed to publish" for `requiredToPublish`, then help and the limits in words ("Up to 80 characters.", "From 1 to 10."). The last save's `details.issues` show on their field (`fields//…` → that field), worded per code (`fields.issues.*`); the save error itself stays in the page's `ErrorBanner`. **Translations** (the group's original in the same site): non-localized fields show their control disabled with the original's value and "From the original (English) · Open the original" (a `Link` to its editor); empty localized fields show the original's value as their placeholder, or "The original: …" under controls without one (choices, dates, yes/no, images). -- **Entry editor › references and repeaters** (RFC-0045 §5; words in `fields.reference.*`, `fields.repeater.*`, `fields.usedBy.*`): a reference is `FieldReference` — `ReferenceList` of the stored targets (each group's member in the entry's language, else the default language's, else the first, read from `GET …/entries/groups`; any status, so drafts and archived ones say they will not show and deleted ones say "Not found") and an `EntryPicker` over `GET …/entries/pick` (the allowed collections, keyset cursor; the entry's own group and the chosen ones can't be picked); the trigger is secondary "Choose entry", "Change" for a chosen single target, "Add entries" with "Holds up to n entries." for several. A repeater is `RepeaterField` with each sub-field's `FieldControl` (images through `FieldImage`, references through `FieldReference`); its legend is `fields.editor.itemLegend` ("Item 2: Ada Lovelace", from `itemLabel`), "Remove" shows an info toast "Item 2: … removed" with "Undo" for 10 s (scoped to the entry: it closes when the editor leaves it; Undo writes over the current draft, and an error toast says when the list is full again), a save leaves out items that hold no value, a value a running type change has not converted shows read-only, as stored, with "Being changed", and a list that is not localized says "Not translated: this list is shared by every language." on multilingual sites. Save issues map by path (`fields/speakers/2/person` → that control). **Used by** (`UsedByCard`, a flush `SectionCard` after Fields, only when some field of the site can reference the collection): `UsedByList` from `GET …/entries/:id/references`, published referrers first, "Show more" while pages remain, `SectionEmpty` (`LinkSimple`) when none. Both cards load through `EntryFieldCards` as their own lazy chunks. +- **Entry editor › references and repeaters** (RFC-0045 §5; words in `fields.reference.*`, `fields.repeater.*`, `fields.usedBy.*`): a reference is `FieldReference` — `ReferenceList` of the stored targets (each group's member in the entry's language, else the default language's, else the first, read from `GET …/entries/groups`; any status, so drafts and archived ones say they will not show and deleted ones say "Not found") and an `EntryPicker` over `GET …/entries/pick` (the allowed collections, keyset cursor; the entry's own group and the chosen ones can't be picked); the trigger is secondary "Choose entry", "Change" for a chosen single target, "Add entries" with "Holds up to n entries." for several. A repeater is `RepeaterField` with each sub-field's `FieldControl` (images through `FieldImage`, references through `FieldReference`); its legend is `fields.editor.itemLegend` ("Item 2: Ada Lovelace", from `itemLabel`), "Remove" shows an info toast "Item 2: … removed" with "Undo" for 10 s (scoped to the entry: it closes when the editor leaves it; Undo writes over the current draft, and an error toast says when the list is full again), a save leaves out items that hold no value, a value a running type change has not converted shows read-only, as stored, with "Being changed", and a list that is not localized says "Not translated: this list is shared by every language." on multilingual sites. Save issues map by path (`fields/speakers/2/person` → that control). **Used by** (`UsedByCard`, a flush `SectionCard` with the reach cards after "In menus", outside the read-only fieldset so "Show more" works on an article the person can't edit, only when some field of the site can reference the collection): `UsedByList` from `GET …/entries/:id/references`, published referrers first, "Show more" while pages remain, `SectionEmpty` (`LinkSimple`) when none. Fields loads through `EntryFieldCards`, Used by through `EntryUsedByCard`, each as its own lazy chunk. Read-only, every repeater item is open. A save's item issues are mapped back onto the draft's items, since the save leaves empty items out. - **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). diff --git a/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md b/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md index 92b7e890..0437e34e 100644 --- a/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md +++ b/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md @@ -1409,3 +1409,8 @@ Round 1 dropped the "fits" shortcut for text ↔ reference, but then the table r - **The groups read** sends only well-formed group ids. Anything else (text a running conversion has not rewritten) shows as "Not found". A failed read shows an error banner with "Try again". - **During a running field operation** the editor reads values through the markers. A top-level rename's old key and a sub-field rename's old key in each item are shown under the new key, and a write leaves only the new key. A value a type change has not converted yet (its shape is not the new type's, `fitsShape`) is shown read-only, as stored, with "Being changed", top level or per item. The UI never converts it; phase B does. - **No empty item is sent.** "Add item" keeps its empty item in the form while it is being filled; the save leaves out items without a value (the collection's repeater definitions only), and a list left empty is no value. + +#### Review round 2 (2026-11-13, PM verification of 5311a1b9) + +- **Item issues after empty items are left out.** The server counts the items that were sent; the editor maps each issue's item index back onto the draft's items (`sentIndexes`, `draftPath`) before the error map is built, so a problem never lands on an empty item. +- **Read-only.** Every repeater item is open when the form is read-only (its toggles sit in the editor's disabled fieldset). "Used by" moved to the reach cards, beside "In menus" and outside that fieldset, so its "Show more" works on an article the person can't edit. diff --git a/packages/ui/src/blocks/RepeaterField.tsx b/packages/ui/src/blocks/RepeaterField.tsx index 360574ab..240acc5a 100644 --- a/packages/ui/src/blocks/RepeaterField.tsx +++ b/packages/ui/src/blocks/RepeaterField.tsx @@ -93,16 +93,18 @@ export interface RepeaterFieldProps { * remove. Moving is done with buttons only. After "Add item" focus goes to the new item's first * control; after "Remove" to the next item's toggle, else "Add item", and the host gets an undo. * Moves and removals are announced in a polite live region. Items with problems open when a new - * `itemErrors` arrives; collapsed ones are listed above the items. + * `itemErrors` arrives; collapsed ones are listed above the items. Read-only, every item is open. */ export function RepeaterField(props: RepeaterFieldProps) { const { items, max, labels, readOnly = false } = props; const id = useId(); const { root, keys, open, said, errors, toggle, add, move, duplicate, remove, reveal, setSub } = useRepeater(props); + // Read-only, every item is open: its toggle may be off (a read-only editor's disabled fieldset). + const isOpen = (key: string) => readOnly || open.has(key); const collapsedInvalid = keys.current .map((key, i) => ({ key, i })) - .filter(({ key }) => errors.has(key) && !open.has(key)); + .filter(({ key }) => errors.has(key) && !isOpen(key)); const full = items.length >= max; return (
(props: RepeaterFieldPro itemKey={key} index={i} count={items.length} - open={open.has(key)} + open={isOpen(key)} onOpen={(on) => toggle(key, on)} legend={props.legend(i, item)} errors={errors.get(key)} diff --git a/packages/ui/test/entry-fields-form.test.tsx b/packages/ui/test/entry-fields-form.test.tsx index 80343b69..128673f6 100644 --- a/packages/ui/test/entry-fields-form.test.tsx +++ b/packages/ui/test/entry-fields-form.test.tsx @@ -487,3 +487,34 @@ describe("EntryFieldsForm during a running field operation (RFC-0045 §7)", () = expect(onChange).not.toHaveBeenCalled(); }); }); + +describe("EntryFieldsForm read-only (RFC-0045 §5)", () => { + it("shows every repeater item open inside a disabled fieldset", () => { + const people = [1, 2, 3, 4].map((n) => ({ talk: `Talk ${n}` })); + render( +
+ {}} + locale="en" + defaultLocale="en" + timeZone="UTC" + zoneLabel={(z) => z} + labels={labels} + disabled + /> +
, + ); + const talks = screen.getAllByRole("textbox", { name: "Talk" }); + expect(talks.map((t) => (t as HTMLInputElement).value)).toEqual(people.map((p) => p.talk)); + }); +}); From 6a2b09520a5f1ef1385d573e4ab13ea1d75d9f6c Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:50:36 +0300 Subject: [PATCH 4/4] fix(studio): RFC-0045 step 6a review round 3 (PR #219) - Duplicate leaves empty repeater items out, like a save (duplicateOf runs withoutEmptyItems; the page function shrinks, baseline tightened). - Item issues map back through the entry the failed save sent (the mutation's variables, passed as `save`), not the draft at error time. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .../studio/src/components/EntryFieldCards.tsx | 8 ++-- .../src/components/fields/EntryFieldsCard.tsx | 12 +++-- apps/studio/src/routes/ArticleEditorPage.tsx | 17 ++++--- apps/studio/test/fields-references.test.tsx | 46 +++++++++++++++++++ ...ctions-stage-4-references-and-repeaters.md | 5 ++ scripts/size-baseline.json | 2 +- 6 files changed, 75 insertions(+), 15 deletions(-) diff --git a/apps/studio/src/components/EntryFieldCards.tsx b/apps/studio/src/components/EntryFieldCards.tsx index 4bdcecc6..45e63263 100644 --- a/apps/studio/src/components/EntryFieldCards.tsx +++ b/apps/studio/src/components/EntryFieldCards.tsx @@ -33,14 +33,15 @@ export function EntryFieldCards({ site, entry, onChange, - error, + save, disabled, }: { siteId: string; site: Site | undefined; entry: ContentEntry; onChange: (fields: Record) => void; - error: unknown; + /** The editor's save: its error, and the entry it sent (the issues' item indices count its items). */ + save: { error: unknown; variables?: ContentEntry | undefined }; disabled: boolean; }) { if (!site?.collections?.[entry.collection]?.fields) return null; @@ -51,7 +52,8 @@ export function EntryFieldCards({ site={site} entry={entry} onChange={onChange} - error={error} + error={save.error} + sent={save.variables} disabled={disabled} /> diff --git a/apps/studio/src/components/fields/EntryFieldsCard.tsx b/apps/studio/src/components/fields/EntryFieldsCard.tsx index 3e2732ff..a3ab11d8 100644 --- a/apps/studio/src/components/fields/EntryFieldsCard.tsx +++ b/apps/studio/src/components/fields/EntryFieldsCard.tsx @@ -142,6 +142,7 @@ export function EntryFieldsCard({ entry, onChange, error, + sent, disabled, }: { siteId: string; @@ -150,6 +151,8 @@ export function EntryFieldsCard({ onChange: (fields: Record) => void; /** The last save's error: its `details.issues` show on the fields. */ error: unknown; + /** The entry that save sent (before empty items were left out); the draft when not given. */ + sent?: ContentEntry | undefined; disabled: boolean; }) { const { t, locale } = useI18n("fields", "bodyEditor", "media"); @@ -157,10 +160,11 @@ export function EntryFieldsCard({ const definitions = site.collections?.[entry.collection]?.fields ?? []; const original = useOriginal(siteId, entry); const labels = useFormLabels(t, original.data?.locale, locale); - // The draft as it was when the error arrived: a save leaves empty items out, so the server's item - // indices are mapped back onto the draft's once, then follow their items (RepeaterField). - const drafted = useRef({ entry, site }); - drafted.current = { entry, site }; + // What the failed save sent: it left empty items out, so the server's item indices are mapped + // back onto those items once, then follow their items (RepeaterField). Not the draft at error + // time, which may have changed while the save was in flight. + const drafted = useRef({ entry: sent ?? entry, site }); + drafted.current = { entry: sent ?? entry, site }; const errors = useMemo(() => { const sent = sentIndexes(drafted.current.entry, drafted.current.site); const out: Record = {}; diff --git a/apps/studio/src/routes/ArticleEditorPage.tsx b/apps/studio/src/routes/ArticleEditorPage.tsx index 929dda2f..e8630e51 100644 --- a/apps/studio/src/routes/ArticleEditorPage.tsx +++ b/apps/studio/src/routes/ArticleEditorPage.tsx @@ -71,8 +71,14 @@ import { missingPrimaries } from "../lib/taxonomy.ts"; import { isDirty, setOptional } from "../lib/util.ts"; import { slugify } from "../lib/workflow.ts"; -/** A copy of the article as a new draft with a free slug. */ -function duplicateOf(entry: ContentEntry, title: string): NewEntry { +/** A copy of the article as a new draft with a free slug (no empty repeater item, RFC-0045 §1). */ +export function duplicateOf( + draft: ContentEntry, + t: ReturnType, + site?: Site, +): NewEntry { + const entry = withoutEmptyItems(draft, site); + const title = t("editor.copyTitle", { title: entry.title || t("articles.untitled") }); const suffix = `-copy-${Date.now().toString(36).slice(-4)}`; return { collection: entry.collection, @@ -195,10 +201,7 @@ export function ArticleEditorPage() { }); const duplicate = useMutation({ mutationFn: (entry: ContentEntry) => - api.entries.create( - siteId, - duplicateOf(entry, t("editor.copyTitle", { title: entry.title || t("articles.untitled") })), - ), + api.entries.create(siteId, duplicateOf(entry, t, site.data)), onSuccess: async (created) => { await qc.invalidateQueries({ queryKey: ["entries", siteId] }); toast.success(t("editor.duplicated"), created.slug); @@ -450,7 +453,7 @@ export function ArticleEditorPage() { site={site.data} entry={draft} onChange={(fields) => setDraft((d) => d && { ...d, fields })} - error={save.error} + save={save} disabled={readOnlyReason !== undefined || locked} /> { @@ -91,10 +92,12 @@ const card = (groupId: string, title: string, status = "published", locale = "en function Harness({ entry = ENTRY, error, + sent, onFields = () => {}, }: { entry?: ContentEntry; error?: unknown; + sent?: ContentEntry; onFields?: (f: Record) => void; }) { const [draft, setDraft] = useState(entry); @@ -108,6 +111,7 @@ function Harness({ onFields(fields); }} error={error} + sent={sent} disabled={false} /> ); @@ -252,6 +256,48 @@ describe("Entry editor › item issues after empty items are left out", () => { }); }); +describe("Entry editor › item issues map through what the save sent", () => { + it("uses the entry the save sent, not the draft edited while it was in flight", async () => { + serve(groups({ entry_hall0001: [card("entry_hall0001", "Main hall")] })); + const sent = { + ...ENTRY, + fields: { speakers: [{ name: "Ada", role: "Host" }, {}, { name: "Grace" }] }, + } as unknown as ContentEntry; + // Item 2 was filled in after the save left: the draft no longer has an empty item. + const draft = { + ...sent, + fields: { speakers: [{ name: "Ada", role: "Host" }, { name: "Linus" }, { name: "Grace" }] }, + } as unknown as ContentEntry; + const error = new ApiRequestError(422, { + error: "required_field", + message: "needed", + details: { + issues: [{ path: "fields/speakers/1/role", code: "required_field", message: "needed" }], + }, + }); + render(wrap()); + const grace = await screen.findByRole("group", { name: "Item 3: Grace" }); + expect(grace).toHaveAttribute("aria-invalid", "true"); + expect(screen.getByRole("group", { name: "Item 2: Linus" })).not.toHaveAttribute( + "aria-invalid", + ); + }); +}); + +describe("Duplicate leaves empty repeater items out (RFC-0045 §1)", () => { + it("copies the fields without empty items", () => { + const entry = { + ...ENTRY, + fields: { venue: "entry_hall0001", speakers: [{}, { name: "Ada" }] }, + } as unknown as ContentEntry; + const t = ((key: string) => key) as unknown as Parameters[1]; + expect(duplicateOf(entry, t, SITE).fields).toEqual({ + venue: "entry_hall0001", + speakers: [{ name: "Ada" }], + }); + }); +}); + describe("Entry editor › repeater undo (review fixes)", () => { it("keeps every edit made after the removal", async () => { serve(groups({ entry_hall0001: [card("entry_hall0001", "Main hall")] })); diff --git a/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md b/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md index 0437e34e..2ddb2ffc 100644 --- a/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md +++ b/docs/rfcs/0045-collections-stage-4-references-and-repeaters.md @@ -1414,3 +1414,8 @@ Round 1 dropped the "fits" shortcut for text ↔ reference, but then the table r - **Item issues after empty items are left out.** The server counts the items that were sent; the editor maps each issue's item index back onto the draft's items (`sentIndexes`, `draftPath`) before the error map is built, so a problem never lands on an empty item. - **Read-only.** Every repeater item is open when the form is read-only (its toggles sit in the editor's disabled fieldset). "Used by" moved to the reach cards, beside "In menus" and outside that fieldset, so its "Show more" works on an article the person can't edit. + +#### Review round 3 (2026-11-13, PM check of 98a7e3b3) + +- **Duplicate** copies the fields without empty repeater items too (`duplicateOf` runs the same `withoutEmptyItems` as a save). +- **The index map is built from what the failed save sent** (the mutation's variables), not from the draft when the error arrives, which may have changed while the save was in flight. diff --git a/scripts/size-baseline.json b/scripts/size-baseline.json index 4663c741..fd24e680 100644 --- a/scripts/size-baseline.json +++ b/scripts/size-baseline.json @@ -180,7 +180,7 @@ "lines": 191 }, "apps/studio/src/routes/ArticleEditorPage.tsx::ArticleEditorPage": { - "lines": 442 + "lines": 439 }, "apps/studio/src/routes/ArticlesPage.tsx::ArticlesPage": { "lines": 192