@pramen/cms-editor 0.0.59 → 0.0.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/buzola.gen.ts CHANGED
@@ -6,40 +6,73 @@ import { buildRouteTree } from '@buzola/router';
6
6
  import type { RouteConfig } from '@buzola/router';
7
7
 
8
8
  import Route0 from './routes/_layout';
9
- const Route1 = () => import('./routes/collection-item').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
10
- const Route2 = () => import('./routes/collection').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
11
- const Route3 = () => import('./routes/home').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
12
- const Route4 = () => import('./routes/media').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
13
- const Route5 = () => import('./routes/page').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
14
- const Route6 = () => import('./routes/settings').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
15
- const Route7 = () => import('./routes/type').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
16
- const Route8 = () => import('./routes/users').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
17
- const Route9 = () => import('./routes/_404').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
9
+ const Route1 = () => import('./routes/admin-page').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
10
+ const Route2 = () => import('./routes/block-type').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
11
+ const Route3 = () => import('./routes/collection-item').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
12
+ const Route4 = () => import('./routes/collection').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
13
+ const Route5 = () => import('./routes/content-type').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
14
+ const Route6 = () => import('./routes/home').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
15
+ const Route7 = () => import('./routes/media').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
16
+ const Route8 = () => import('./routes/menu').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
17
+ const Route9 = () => import('./routes/menus').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
18
+ const Route10 = () => import('./routes/page').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
19
+ const Route11 = () => import('./routes/redirects').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
20
+ const Route12 = () => import('./routes/schema').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
21
+ const Route13 = () => import('./routes/settings').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
22
+ const Route14 = () => import('./routes/taxonomies').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
23
+ const Route15 = () => import('./routes/taxonomy').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
24
+ const Route16 = () => import('./routes/type').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
25
+ const Route17 = () => import('./routes/users').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
26
+ const Route18 = () => import('./routes/widget-area').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
27
+ const Route19 = () => import('./routes/widgets').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
28
+ const Route20 = () => import('./routes/_404').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
18
29
 
19
30
  // Module augmentation for type-safe page-centric routing
20
31
  declare module '@buzola/router' {
21
32
  interface BuzolaPageMap {
33
+ 'admin-page': { slug: string };
34
+ 'block-type': { slug: string };
22
35
  'collection-item': { slug: string; id: string };
23
36
  'collection': { slug: string };
37
+ 'content-type': { slug: string };
24
38
  'home': {};
25
39
  'media': {};
40
+ 'menu': { name: string };
41
+ 'menus': {};
26
42
  'page': { pageId: string; tab?: string };
43
+ 'redirects': {};
44
+ 'schema': {};
27
45
  'settings': {};
46
+ 'taxonomies': {};
47
+ 'taxonomy': { slug: string };
28
48
  'type': { slug: string };
29
49
  'users': {};
50
+ 'widget-area': { name: string };
51
+ 'widgets': {};
30
52
  }
31
53
  }
32
54
 
33
55
  // Page registry — page ID → route pattern
34
56
  export const pageRegistry: Record<string, string> = {
57
+ 'admin-page': '/apps/:slug',
58
+ 'block-type': '/schema/blocks/:slug',
35
59
  'collection-item': '/collections/:slug/:id',
36
60
  'collection': '/collections/:slug',
61
+ 'content-type': '/schema/content/:slug',
37
62
  'home': '/',
38
63
  'media': '/media',
64
+ 'menu': '/menus/:name',
65
+ 'menus': '/menus',
39
66
  'page': '/pages/:pageId',
67
+ 'redirects': '/redirects',
68
+ 'schema': '/schema',
40
69
  'settings': '/settings',
70
+ 'taxonomies': '/taxonomies',
71
+ 'taxonomy': '/taxonomies/:slug',
41
72
  'type': '/types/:slug',
42
73
  'users': '/users',
74
+ 'widget-area': '/widgets/:name',
75
+ 'widgets': '/widgets',
43
76
  };
44
77
 
45
78
  const routeConfigs: RouteConfig[] = [
@@ -49,55 +82,116 @@ const routeConfigs: RouteConfig[] = [
49
82
  isLayout: true,
50
83
  children: [
51
84
  {
52
- path: '/collection-item',
53
- matchPath: '/collections/:slug/:id',
85
+ path: '/admin-page',
86
+ matchPath: '/apps/:slug',
54
87
  component: lazy(Route1),
55
88
  preload: Route1,
56
89
  },
57
90
  {
58
- path: '/collection',
59
- matchPath: '/collections/:slug',
91
+ path: '/block-type',
92
+ matchPath: '/schema/blocks/:slug',
60
93
  component: lazy(Route2),
61
94
  preload: Route2,
62
95
  },
63
96
  {
64
- path: '/home',
65
- matchPath: '/',
97
+ path: '/collection-item',
98
+ matchPath: '/collections/:slug/:id',
66
99
  component: lazy(Route3),
67
100
  preload: Route3,
68
101
  },
69
102
  {
70
- path: '/media',
103
+ path: '/collection',
104
+ matchPath: '/collections/:slug',
71
105
  component: lazy(Route4),
72
106
  preload: Route4,
73
107
  },
74
108
  {
75
- path: '/page',
76
- matchPath: '/pages/:pageId',
109
+ path: '/content-type',
110
+ matchPath: '/schema/content/:slug',
77
111
  component: lazy(Route5),
78
112
  preload: Route5,
79
113
  },
80
114
  {
81
- path: '/settings',
115
+ path: '/home',
116
+ matchPath: '/',
82
117
  component: lazy(Route6),
83
118
  preload: Route6,
84
119
  },
85
120
  {
86
- path: '/type',
87
- matchPath: '/types/:slug',
121
+ path: '/media',
88
122
  component: lazy(Route7),
89
123
  preload: Route7,
90
124
  },
91
125
  {
92
- path: '/users',
126
+ path: '/menu',
127
+ matchPath: '/menus/:name',
93
128
  component: lazy(Route8),
94
129
  preload: Route8,
95
130
  },
96
131
  {
97
- path: '/:__notFound+',
132
+ path: '/menus',
98
133
  component: lazy(Route9),
99
134
  preload: Route9,
100
135
  },
136
+ {
137
+ path: '/page',
138
+ matchPath: '/pages/:pageId',
139
+ component: lazy(Route10),
140
+ preload: Route10,
141
+ },
142
+ {
143
+ path: '/redirects',
144
+ component: lazy(Route11),
145
+ preload: Route11,
146
+ },
147
+ {
148
+ path: '/schema',
149
+ component: lazy(Route12),
150
+ preload: Route12,
151
+ },
152
+ {
153
+ path: '/settings',
154
+ component: lazy(Route13),
155
+ preload: Route13,
156
+ },
157
+ {
158
+ path: '/taxonomies',
159
+ component: lazy(Route14),
160
+ preload: Route14,
161
+ },
162
+ {
163
+ path: '/taxonomy',
164
+ matchPath: '/taxonomies/:slug',
165
+ component: lazy(Route15),
166
+ preload: Route15,
167
+ },
168
+ {
169
+ path: '/type',
170
+ matchPath: '/types/:slug',
171
+ component: lazy(Route16),
172
+ preload: Route16,
173
+ },
174
+ {
175
+ path: '/users',
176
+ component: lazy(Route17),
177
+ preload: Route17,
178
+ },
179
+ {
180
+ path: '/widget-area',
181
+ matchPath: '/widgets/:name',
182
+ component: lazy(Route18),
183
+ preload: Route18,
184
+ },
185
+ {
186
+ path: '/widgets',
187
+ component: lazy(Route19),
188
+ preload: Route19,
189
+ },
190
+ {
191
+ path: '/:__notFound+',
192
+ component: lazy(Route20),
193
+ preload: Route20,
194
+ },
101
195
  ],
102
196
  },
103
197
  ];
package/src/chrome.ts ADDED
@@ -0,0 +1,16 @@
1
+ // The layout primitives every screen shares.
2
+ //
3
+ // A LEAF module on purpose. These were copied into four files, and the obvious fix — put
4
+ // them in `components.tsx`, which already has them — is not available: `components.tsx`
5
+ // imports from `furniture.tsx` (for `flattenTerms`, used by the page editor's Terms panel),
6
+ // so `furniture.tsx` importing back would be a cycle. Constants and class strings have no
7
+ // dependencies of their own, so they belong below both.
8
+ //
9
+ // Class strings rather than components, because that is what the call sites want: most
10
+ // apply them to an element they are already styling for their own reasons.
11
+
12
+ /** The page gutter every full-width screen uses. */
13
+ export const WRAP = "mx-auto max-w-[1200px] px-7 pb-8 pt-2";
14
+
15
+ /** One row in a list — a card-surfaced strip with the standard inset. */
16
+ export const ROW = "flex items-center gap-3 rounded-[14px] border border-transparent bg-surface-card px-[18px] py-3.5";
@@ -6,19 +6,26 @@ import { Button, Heading, Input, ModalDialog, ModalOverlay, ModalSurface, Textar
6
6
  import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
7
7
  import { Api, ApiError } from "./api";
8
8
  import { CONTROL, FieldForm, formatWhen, fromLocalInput, slugify, toLocalInput } from "./fields";
9
+ import { ROW, WRAP } from "./chrome";
9
10
  import type { Config } from "./api";
10
11
  import { useApp, type Me } from "./app-context";
11
12
  import { isRichTextDoc, richTextToPlainText } from "./rich-text";
12
- import type { AssembledPage, AuditEntry, BlockType, CmsCapabilities, CollectionMeta, ContentType, FieldDefinition, FieldValue, FieldValues, Media, Page, RegionDefinition, RenderedBlock } from "./types";
13
+ import { flattenTerms } from "./furniture";
14
+ import type { AssembledPage, AuditEntry, BlockType, CmsCapabilities, CollectionMeta, ContentType, FieldDefinition, FieldValue, FieldValues, Media, Page, RegionDefinition, RenderedBlock, Taxonomy, Term } from "./types";
13
15
 
14
- export type InspectorTab = "settings" | "seo" | "workflow" | "i18n" | "audit";
15
- export const INSPECTOR_TABS: InspectorTab[] = ["settings", "seo", "workflow", "i18n", "audit"];
16
+ export type InspectorTab = "settings" | "seo" | "workflow" | "i18n" | "terms" | "audit";
17
+ export const INSPECTOR_TABS: InspectorTab[] = ["settings", "seo", "workflow", "i18n", "terms", "audit"];
16
18
 
17
19
  /** The tabs a deployment actually shows. ONE definition, used by the tab bar, the panel
18
20
  * switch and the route's deep-link fallback — three places that previously each re-derived
19
- * "is i18n visible?" and could disagree. */
20
- export function visibleTabs(multilingual: boolean): InspectorTab[] {
21
- return multilingual ? INSPECTOR_TABS : INSPECTOR_TABS.filter((t) => t !== "i18n");
21
+ * "is i18n visible?" and could disagree.
22
+ *
23
+ * `terms` follows `siteFurniture` for the same reason `i18n` follows `multilingual`: on a
24
+ * server without the taxonomy handlers the panel could only ever render an error. With them
25
+ * and no vocabularies defined it renders its own empty state, which is the honest answer
26
+ * and points at the screen that fixes it. */
27
+ export function visibleTabs(multilingual: boolean, siteFurniture = false): InspectorTab[] {
28
+ return INSPECTOR_TABS.filter((t) => (t === "i18n" ? multilingual : t === "terms" ? siteFurniture : true));
22
29
  }
23
30
 
24
31
  /** Collections-only deployments hide the block/page builder entirely. Read in one place so
@@ -48,8 +55,6 @@ export function splitsByType(contentTypes: ContentType[] | null, cms: CmsCapabil
48
55
 
49
56
  // --- presentational primitives (podoba tokens; replaces styles.ts classes) ---
50
57
 
51
- const ROW = "flex items-center gap-3 rounded-[14px] border border-transparent bg-surface-card px-[18px] py-3.5";
52
- const WRAP = "mx-auto max-w-[1200px] px-7 pb-8 pt-2";
53
58
 
54
59
  function Hero({ lead, em, children }: { lead: string; em: string; children?: ReactNode }) {
55
60
  return (
@@ -666,7 +671,7 @@ export function CollectionEditor({ api, def, id, onSaved, onDeleted, onBack, onE
666
671
  // --- page editor -------------------------------------------------------------
667
672
 
668
673
  export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange, registerGuard }: { api: Api; page: Page; blockTypes: BlockType[]; tab: InspectorTab; onTab: (t: InspectorTab) => void; onBack: () => void; onChange: (p: Page) => void; registerGuard: (fn: (() => boolean) | null) => void }) {
669
- const { cms: { multilingual } } = useApp();
674
+ const { cms: { multilingual, siteFurniture, canEdit } } = useApp();
670
675
  const [ct, setCt] = useState<ContentType | null>(null);
671
676
  const [assembled, setAssembled] = useState<AssembledPage | null>(null);
672
677
  const [err, setErr] = useState("");
@@ -905,7 +910,7 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
905
910
 
906
911
  <div className="overflow-auto rounded-panel border border-border bg-surface-card p-5">
907
912
  <div className="mb-3 flex gap-1">
908
- {visibleTabs(multilingual).map((t) => (
913
+ {visibleTabs(multilingual, siteFurniture).map((t) => (
909
914
  <Button key={t} variant="ghost" size="sm" className={tab === t ? "bg-surface-muted text-fg" : "text-fg-muted"} onPress={() => onTab(t)}>{t}</Button>
910
915
  ))}
911
916
  </div>
@@ -913,6 +918,7 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
913
918
  {tab === "seo" ? <SeoPanel api={api} page={page} onError={setErr} /> : null}
914
919
  {tab === "workflow" ? <Workflow api={api} page={page} onChanged={(p) => { onChange(p); }} onError={setErr} /> : null}
915
920
  {tab === "i18n" && multilingual ? <I18n api={api} page={page} onError={setErr} /> : null}
921
+ {tab === "terms" ? <PageTerms api={api} pageId={page.id} canEdit={canEdit} onError={setErr} /> : null}
916
922
  {tab === "audit" ? <AuditLog api={api} pageId={page.id} onError={setErr} /> : null}
917
923
  </div>
918
924
  </div>
@@ -1401,6 +1407,92 @@ function I18n({ api, page, onError }: { api: Api; page: Page; onError: (s: strin
1401
1407
  );
1402
1408
  }
1403
1409
 
1410
+ /** The page's taxonomy terms.
1411
+ *
1412
+ * Assignment has to live in the page editor: `cms_page_terms` links a term to a PAGE, so
1413
+ * the vocabulary screen can define terms but has no way to attach one — a taxonomy with no
1414
+ * assignment surface is a list of words.
1415
+ *
1416
+ * `setPageTerms` replaces the whole selection rather than adding and removing one at a
1417
+ * time, so the panel holds the selection and saves it as a unit. Two calls each patching
1418
+ * one end of it race into a state neither asked for.
1419
+ */
1420
+ function PageTerms({ api, pageId, canEdit, onError }: { api: Api; pageId: string; canEdit: boolean; onError: (s: string) => void }) {
1421
+ const [taxa, setTaxa] = useState<Taxonomy[] | null>(null);
1422
+ const [terms, setTerms] = useState<Record<string, Term[]>>({});
1423
+ const [selected, setSelected] = useState<Set<string>>(() => new Set());
1424
+ const [busy, setBusy] = useState(false);
1425
+ const [ok, setOk] = useState(false);
1426
+
1427
+ useEffect(() => {
1428
+ let live = true;
1429
+ (async () => {
1430
+ try {
1431
+ const list = await api.listTaxonomies();
1432
+ if (!live) return;
1433
+ setTaxa(list);
1434
+ // In parallel, and alongside the page's own assignments. Awaiting one vocabulary at
1435
+ // a time made the panel's open cost N+1 serial round trips for data that has no
1436
+ // ordering dependency between the calls.
1437
+ const [trees, assigned] = await Promise.all([
1438
+ Promise.all(list.map(async (t) => [t.slug, flattenTerms(await api.getTermTree(t.slug)).map((f) => f.term)] as const)),
1439
+ api.listPageTerms(pageId),
1440
+ ]);
1441
+ if (!live) return;
1442
+ setTerms(Object.fromEntries(trees));
1443
+ setSelected(new Set(assigned.map((t) => t.id)));
1444
+ } catch (e) {
1445
+ if (live) onError(errMsg(e));
1446
+ }
1447
+ })();
1448
+ return () => { live = false; };
1449
+ }, [api, pageId, onError]);
1450
+
1451
+ const toggle = (id: string) => {
1452
+ setSelected((prev) => {
1453
+ const next = new Set(prev);
1454
+ if (next.has(id)) next.delete(id); else next.add(id);
1455
+ return next;
1456
+ });
1457
+ };
1458
+
1459
+ const save = async () => {
1460
+ setBusy(true);
1461
+ try {
1462
+ await api.setPageTerms(pageId, [...selected]);
1463
+ setOk(true);
1464
+ setTimeout(() => setOk(false), 1200);
1465
+ } catch (e) { onError(errMsg(e)); } finally { setBusy(false); }
1466
+ };
1467
+
1468
+ if (taxa === null) return <p className="text-fg-subtle">Loading…</p>;
1469
+ if (taxa.length === 0) return <p className="text-fg-subtle">No vocabularies defined yet.</p>;
1470
+
1471
+ return (
1472
+ <div className="flex flex-col gap-4">
1473
+ {ok ? <Banner ok>saved</Banner> : null}
1474
+ {taxa.map((t) => (
1475
+ <div key={t.id}>
1476
+ <Section>{t.label}</Section>
1477
+ <div className="flex flex-col gap-1">
1478
+ {(terms[t.slug] ?? []).length === 0 ? <span className="text-caption text-fg-subtle">No terms yet.</span> : null}
1479
+ {(terms[t.slug] ?? []).map((term) => (
1480
+ <label key={term.id} className="flex items-center gap-2">
1481
+ {/* `setPageTerms` is editor-gated, so a reviewer got live checkboxes and a
1482
+ Save button that 403s — the one new write surface that did not take
1483
+ `canEdit`. */}
1484
+ <input type="checkbox" disabled={!canEdit} checked={selected.has(term.id)} onChange={() => toggle(term.id)} />
1485
+ <span className="text-sm text-fg">{term.label}</span>
1486
+ </label>
1487
+ ))}
1488
+ </div>
1489
+ </div>
1490
+ ))}
1491
+ {canEdit ? <Button size="sm" className="self-start" onPress={save} isDisabled={busy}>{busy ? "Saving…" : "Save terms"}</Button> : null}
1492
+ </div>
1493
+ );
1494
+ }
1495
+
1404
1496
  function AuditLog({ api, pageId, onError }: { api: Api; pageId: string; onError: (s: string) => void }) {
1405
1497
  const [rows, setRows] = useState<AuditEntry[]>([]);
1406
1498
  useEffect(() => {
package/src/fields.tsx CHANGED
@@ -8,10 +8,10 @@ import Highlight from "@tiptap/extension-highlight";
8
8
  import TaskItem from "@tiptap/extension-task-item";
9
9
  import TaskList from "@tiptap/extension-task-list";
10
10
  import StarterKit from "@tiptap/starter-kit";
11
- import { useEffect, useRef, useState, type DragEvent, type ReactNode } from "react";
11
+ import { useCallback, useEffect, useRef, useState, type DragEvent, type ReactNode } from "react";
12
12
  import type { Api } from "./api";
13
13
  import { isRichTextDoc, richTextToPlainText } from "./rich-text";
14
- import type { FieldDefinition, FieldValue, FieldValues, Media, RichTextDoc } from "./types";
14
+ import type { FieldDefinition, FieldValue, FieldValues, Media, ReferenceOption, ReferenceResult, RichTextDoc } from "./types";
15
15
 
16
16
  // Tokenized bare control (podoba's filled-field skin) for the native inputs that
17
17
  // don't map cleanly onto a podoba primitive (number/date/select/file).
@@ -260,6 +260,16 @@ function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def:
260
260
  <MediaField value={value as string | null} onChange={onChange} api={api} />
261
261
  </FieldShell>
262
262
  );
263
+ case "reference":
264
+ // Deliberately NOT FieldShell: the control is a row of <button>s, and a <label>
265
+ // wrapper would forward a click on the label text to the first one (opening the
266
+ // picker). Same reason `publish` and `boolean` opt out.
267
+ return (
268
+ <div className="flex w-full flex-col gap-2">
269
+ {label === undefined ? null : <span className="text-sm font-medium text-fg">{label}</span>}
270
+ <ReferenceField def={def} value={value} onChange={onChange} api={api} ariaLabel={hideLabelAs} />
271
+ </div>
272
+ );
263
273
  case "group":
264
274
  return (
265
275
  <FieldShell label={label}>
@@ -742,3 +752,248 @@ export function MediaPicker({ api, onClose, onPick }: { api: Api; onClose: () =>
742
752
  </ModalOverlay>
743
753
  );
744
754
  }
755
+
756
+ // --- reference ------------------------------------------------------------------------
757
+ //
758
+ // A pointer to a record that is not this row. `select` + `optionsFrom` already does the
759
+ // easy half — fetch `{ value, label }[]` once, render a `<select>` — and stays the right
760
+ // answer for twenty campaigns. It has no answer for a thousand records: no search, no
761
+ // paging, and no way to render the label of a stored value that is not in the one page it
762
+ // fetched, so an existing row shows a uuid.
763
+ //
764
+ // So this control asks the server two different questions, and the second is the one that
765
+ // makes it work on an existing row:
766
+ //
767
+ // { search, limit, offset } -> browse / search (the picker)
768
+ // { ids: [...] } -> resolve labels (what the closed control renders)
769
+ //
770
+ // The stored value stays an opaque id, so the same field points at a pramen row or at a
771
+ // record in a system we do not own.
772
+
773
+ const REFERENCE_PAGE_SIZE = 20;
774
+
775
+ /** Tolerate a handler that answers with a bare array — the `optionsFrom` shape — so a
776
+ * `select`'s existing options handler can be pointed at a `reference` without a rewrite. */
777
+ function asReferenceResult(raw: unknown): ReferenceResult {
778
+ if (Array.isArray(raw)) return { items: raw as ReferenceOption[] };
779
+ const r = raw as ReferenceResult | null;
780
+ return { items: Array.isArray(r?.items) ? r.items : [], hasMore: r?.hasMore };
781
+ }
782
+
783
+ /** Resolve labels for ids that are already stored.
784
+ *
785
+ * Only ever asks for ids it does not already have a label for, and keeps what it has
786
+ * across renders: a repeater of twenty references would otherwise re-resolve every one of
787
+ * them on every keystroke in a sibling field. */
788
+ function useReferenceLabels(api: Api, from: string | undefined, ids: readonly string[]): Map<string, ReferenceOption> {
789
+ const [known, setKnown] = useState<Map<string, ReferenceOption>>(() => new Map());
790
+ // The ids, as a stable primitive — an array literal is a new identity every render.
791
+ //
792
+ // JSON, not `join(" ")`: an id here is OPAQUE, which is the whole premise of the field, so
793
+ // it may contain a space. Splitting on one turned a single external id into two ids that
794
+ // resolve to nothing, and the control showed "Loading..." forever.
795
+ const key = JSON.stringify(ids);
796
+ useEffect(() => {
797
+ if (!from) return;
798
+ const wanted = JSON.parse(key) as string[];
799
+ const missing = wanted.filter((id) => !known.has(id));
800
+ if (missing.length === 0) return;
801
+ let alive = true;
802
+ api
803
+ .call<ReferenceResult>(from, { ids: missing })
804
+ .then((r) => {
805
+ if (!alive) return;
806
+ const items = asReferenceResult(r).items;
807
+ setKnown((prev) => {
808
+ const next = new Map(prev);
809
+ for (const it of items) next.set(it.value, it);
810
+ // An id the handler did not answer for is recorded as UNRESOLVED rather than left
811
+ // missing — otherwise this effect asks for it again on every render, forever, for
812
+ // a record that has been deleted.
813
+ for (const id of missing) if (!next.has(id)) next.set(id, { value: id, label: "" });
814
+ return next;
815
+ });
816
+ })
817
+ .catch(() => {
818
+ if (!alive) return;
819
+ setKnown((prev) => {
820
+ const next = new Map(prev);
821
+ for (const id of missing) if (!next.has(id)) next.set(id, { value: id, label: "" });
822
+ return next;
823
+ });
824
+ });
825
+ return () => { alive = false; };
826
+ }, [api, from, key, known]);
827
+ return known;
828
+ }
829
+
830
+ /** What a stored id renders as while it is being resolved, and after it could not be. */
831
+ function referenceLabel(id: string, known: Map<string, ReferenceOption>): { label: string; muted: boolean } {
832
+ const hit = known.get(id);
833
+ if (!hit) return { label: "Loading...", muted: true };
834
+ // An empty label is the recorded "no such record" — show the raw id, because that is the
835
+ // only thing left that identifies what the row points at.
836
+ return hit.label ? { label: hit.label, muted: false } : { label: id, muted: true };
837
+ }
838
+
839
+ function ReferenceField({ def, value, onChange, api, ariaLabel }: { def: FieldDefinition; value: FieldValue; onChange: (v: FieldValue) => void; api: Api; ariaLabel?: string }) {
840
+ const multiple = def.multiple === true;
841
+ // `unknown[]` before filtering: `FieldValue` is a union that INCLUDES array members, so
842
+ // `Array.isArray` narrows to a union of array types and the type-guard overload of
843
+ // `.filter` no longer resolves. The guard below is the real narrowing either way.
844
+ const ids: string[] = multiple
845
+ ? (Array.isArray(value) ? (value as unknown[]).filter((v): v is string => typeof v === "string") : [])
846
+ : typeof value === "string" && value !== "" ? [value] : [];
847
+ const known = useReferenceLabels(api, def.referenceFrom, ids);
848
+ const [picking, setPicking] = useState(false);
849
+
850
+ const pick = (opt: ReferenceOption) => {
851
+ if (multiple) {
852
+ if (!ids.includes(opt.value)) onChange([...ids, opt.value]);
853
+ } else {
854
+ onChange(opt.value);
855
+ setPicking(false);
856
+ }
857
+ };
858
+ const remove = (id: string) => onChange(multiple ? ids.filter((v) => v !== id) : null);
859
+
860
+ // Nothing to pick FROM. Said out loud rather than rendered as an empty control that
861
+ // silently does nothing when clicked — this is a schema mistake, and the person seeing it
862
+ // is the one who can fix it.
863
+ if (!def.referenceFrom) {
864
+ return <span className="text-sm text-danger">This reference field declares no `referenceFrom` handler.</span>;
865
+ }
866
+
867
+ return (
868
+ <div className="flex flex-col gap-2">
869
+ {ids.length === 0 ? (
870
+ <span className="text-sm text-fg-muted">Nothing selected.</span>
871
+ ) : (
872
+ <div className="flex flex-col gap-1">
873
+ {ids.map((id) => {
874
+ const { label, muted } = referenceLabel(id, known);
875
+ const hint = known.get(id)?.hint;
876
+ return (
877
+ <div key={id} className="flex items-center gap-2 rounded-[14px] bg-surface-card px-4 py-2.5">
878
+ <span className={`min-w-0 flex-1 truncate text-sm ${muted ? "text-fg-subtle" : "text-fg"}`}>{label}</span>
879
+ {hint ? <span className="shrink-0 text-caption text-fg-subtle">{hint}</span> : null}
880
+ <Button variant="ghost" size="sm" className="text-danger" onPress={() => remove(id)}>remove</Button>
881
+ </div>
882
+ );
883
+ })}
884
+ </div>
885
+ )}
886
+ <Button variant="secondary" size="sm" className="self-start" aria-label={ariaLabel} onPress={() => setPicking(true)}>
887
+ {multiple ? "+ Add" : ids.length > 0 ? "Change" : "Choose"}
888
+ </Button>
889
+ {picking ? (
890
+ <ReferencePicker
891
+ api={api}
892
+ from={def.referenceFrom}
893
+ title={def.label ?? def.name}
894
+ selected={ids}
895
+ multiple={multiple}
896
+ onPick={pick}
897
+ onClose={() => setPicking(false)}
898
+ />
899
+ ) : null}
900
+ </div>
901
+ );
902
+ }
903
+
904
+ function ReferencePicker({ api, from, title, selected, multiple, onPick, onClose }: {
905
+ api: Api;
906
+ from: string;
907
+ title: string;
908
+ selected: readonly string[];
909
+ multiple: boolean;
910
+ onPick: (opt: ReferenceOption) => void;
911
+ onClose: () => void;
912
+ }) {
913
+ const [search, setSearch] = useState("");
914
+ const [items, setItems] = useState<ReferenceOption[]>([]);
915
+ const [hasMore, setHasMore] = useState(false);
916
+ const [loading, setLoading] = useState(true);
917
+ const [err, setErr] = useState("");
918
+ // Debounced: the fetch keys on `term`, so typing does not fire a request per character.
919
+ const [term, setTerm] = useState("");
920
+
921
+ useEffect(() => {
922
+ const id = setTimeout(() => setTerm(search.trim()), 250);
923
+ return () => clearTimeout(id);
924
+ }, [search]);
925
+
926
+ const load = useCallback(
927
+ (offset: number) => {
928
+ setLoading(true);
929
+ setErr("");
930
+ return api
931
+ .call<ReferenceResult>(from, { search: term || undefined, limit: REFERENCE_PAGE_SIZE, offset })
932
+ .then((raw) => {
933
+ const r = asReferenceResult(raw);
934
+ setItems((prev) => (offset === 0 ? r.items : [...prev, ...r.items]));
935
+ // A handler that omits `hasMore` is taken at its word only when it returned a
936
+ // SHORT page; a full one is assumed to have more — the same rule the page and
937
+ // collection lists use.
938
+ setHasMore(r.hasMore ?? r.items.length === REFERENCE_PAGE_SIZE);
939
+ })
940
+ .catch((e: unknown) => setErr(String((e as Error)?.message ?? e)))
941
+ .finally(() => setLoading(false));
942
+ },
943
+ [api, from, term],
944
+ );
945
+
946
+ useEffect(() => { void load(0); }, [load]);
947
+
948
+ return (
949
+ <ModalOverlay isOpen isDismissable onOpenChange={(open) => !open && onClose()}>
950
+ <ModalSurface className="w-full max-w-[560px] px-9 py-8">
951
+ <ModalDialog className="max-h-[86vh] overflow-auto outline-none">
952
+ <Heading level="1" className="mb-5 font-normal">
953
+ Choose <span className="text-fg-subtle">{title.toLowerCase()}</span>
954
+ </Heading>
955
+ <input
956
+ className={`${CONTROL} mb-3`}
957
+ type="search"
958
+ autoFocus
959
+ placeholder="Search"
960
+ aria-label="Search"
961
+ value={search}
962
+ onChange={(e) => setSearch(e.target.value)}
963
+ />
964
+ {err ? <div className="mb-2 rounded-lg border border-danger bg-surface-card px-3.5 py-2.5 text-small text-danger">{err}</div> : null}
965
+ <div className="flex flex-col gap-1">
966
+ {items.map((opt) => {
967
+ const already = selected.includes(opt.value);
968
+ return (
969
+ <button
970
+ key={opt.value}
971
+ type="button"
972
+ disabled={already}
973
+ onClick={() => onPick(opt)}
974
+ className="flex items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-surface-muted disabled:opacity-40"
975
+ >
976
+ <span className="min-w-0 flex-1">
977
+ <span className="block truncate text-sm text-fg">{opt.label}</span>
978
+ {opt.hint ? <span className="block truncate text-caption text-fg-subtle">{opt.hint}</span> : null}
979
+ </span>
980
+ {already ? <span className="shrink-0 text-caption text-fg-subtle">selected</span> : null}
981
+ </button>
982
+ );
983
+ })}
984
+ {!loading && items.length === 0 ? <p className="px-3 py-2 text-sm text-fg-subtle">Nothing matches.</p> : null}
985
+ {loading ? <p className="px-3 py-2 text-sm text-fg-subtle">Loading...</p> : null}
986
+ </div>
987
+ {hasMore && !loading ? (
988
+ <div className="mt-3 text-center">
989
+ <Button variant="secondary" size="sm" onPress={() => void load(items.length)}>Load more</Button>
990
+ </div>
991
+ ) : null}
992
+ <div className="mt-3 text-right">
993
+ <Button variant="ghost" onPress={onClose}>{multiple ? "done" : "close"}</Button>
994
+ </div>
995
+ </ModalDialog>
996
+ </ModalSurface>
997
+ </ModalOverlay>
998
+ );
999
+ }