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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/rfc45-step6a-studio-editor.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 4 additions & 2 deletions apps/studio/bundle-budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
77 changes: 77 additions & 0 deletions apps/studio/src/components/EntryFieldCards.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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(() =>
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 "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,
site,
entry,
onChange,
save,
disabled,
}: {
siteId: string;
site: Site | undefined;
entry: ContentEntry;
onChange: (fields: Record<string, unknown>) => void;
/** 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;
return (
<Suspense fallback={null}>
<EntryFieldsCard
siteId={siteId}
site={site}
entry={entry}
onChange={onChange}
error={save.error}
sent={save.variables}
disabled={disabled}
/>
</Suspense>
);
}

/**
* "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 (
<Suspense fallback={null}>
<UsedByCard siteId={siteId} site={site.data} entry={entry} />
</Suspense>
);
}
2 changes: 2 additions & 0 deletions apps/studio/src/components/Toast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
/**
Expand Down
7 changes: 6 additions & 1 deletion apps/studio/src/components/article-editor/ArticleAside.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -19,6 +23,7 @@ export function ArticleReachCards({
<ReadersCard siteId={siteId} node={entry} kind="article" />
<ArticleCommentsCard siteId={siteId} entry={entry} />
<UsedInMenusCard siteId={siteId} kind="contentEntry" id={entry.id} />
<EntryUsedByCard siteId={siteId} entry={entry} />
<TranslationsCard siteId={siteId} node={entry} dirty={dirty} />
</>
);
Expand Down
82 changes: 78 additions & 4 deletions apps/studio/src/components/fields/EntryFieldsCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ContentEntry, FieldDefinition, Site } from "@cloudhal/core-contracts";
import { memberGroup } from "@cloudhal/core-ts/fields";
import {
EntryFieldsForm,
type EntryFieldsFormLabels,
Expand All @@ -8,16 +9,21 @@ 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 { 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 { FieldImage } from "./FieldImage.tsx";
import { FieldReference } from "./FieldReference.tsx";
import { useGroupCards } from "./useGroupCards.ts";
import { useItemUndo } from "./useItemUndo.ts";

type FT = TFunction<"fields">;

Expand All @@ -27,6 +33,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 =>
Expand All @@ -50,6 +58,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<EntryFieldsFormLabels>(() => {
Expand All @@ -66,6 +94,12 @@ 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"),
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 ?? "" }),
repeater: repeaterLabels(t),
date: {
date: t(`${d}.date`),
pickDate: t(`${d}.pickDate`),
Expand Down Expand Up @@ -108,6 +142,7 @@ export function EntryFieldsCard({
entry,
onChange,
error,
sent,
disabled,
}: {
siteId: string;
Expand All @@ -116,18 +151,44 @@ export function EntryFieldsCard({
onChange: (fields: Record<string, unknown>) => 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");
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);
// 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<string, string> = {};
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[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.
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,
Expand All @@ -150,9 +211,22 @@ export function EntryFieldsCard({
zoneLabel={(zone) => zoneLabel(zone)}
labels={labels}
disabled={disabled}
multilingual={site.locales.length > 1}
referenceTitle={titleOf}
renderImage={(slot) => (
<FieldImage siteId={siteId} defaultLocale={defaultLocale} slot={slot} />
)}
renderReference={(slot) => (
<FieldReference
siteId={siteId}
site={site}
entryLocale={entry.locale}
ownGroup={memberGroup(entry)}
slot={slot}
groups={groups}
/>
)}
onItemRemoved={onItemRemoved}
renderRichText={(slot) => (
<div className="grid min-w-0 gap-1.5" data-field-rich-text={slot.def.key}>
<Label>{slot.label}</Label>
Expand Down
Loading
Loading