@gogitcms/design-system 0.16.0-next.10 → 0.16.0-next.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gogitcms/design-system",
3
- "version": "0.16.0-next.10",
3
+ "version": "0.16.0-next.11",
4
4
  "main": "src/index.ts",
5
5
  "types": "src/index.ts",
6
6
  "// exports": "The root entry is the react-native source the SPAs, desktop app and mobile app consume. ./web is the plain-DOM build for server-rendered surfaces (the Astro docs site) that don't run react-native-web, and ./css ships the tokens as custom properties. The trailing wildcard keeps deep paths resolvable — plugin bundling and the Vite aliases reach into src/ directly.",
@@ -0,0 +1,179 @@
1
+ import React from "react";
2
+ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
3
+ import { ThemeProvider } from "../ThemeProvider";
4
+ import { ContentBrowser, type CmsEntry, type CmsNavSection, type EntryField } from "../components/ContentBrowser";
5
+ import type { ReferenceApi, ReferenceDef, ReferenceTarget } from "../references";
6
+
7
+ // Force the desktop layout (jsdom reports width 0 → mobile otherwise).
8
+ jest.mock("../ThemeProvider", () => {
9
+ const actual = jest.requireActual("../ThemeProvider");
10
+ return { ...actual, useResponsive: () => ({ width: 1300, height: 900, isDesktop: true, isMobile: false }) };
11
+ });
12
+
13
+ const ada: ReferenceTarget = { id: "au1", collection: "authors", path: "content/authors/ada.md", key: "content/authors/ada.md", label: "Ada Lovelace" };
14
+ const bob: ReferenceTarget = { id: "au2", collection: "authors", path: "content/authors/bob.md", key: "content/authors/bob.md", label: "Bob" };
15
+ const byKey: Record<string, ReferenceTarget> = { [ada.key]: ada, [bob.key]: bob };
16
+
17
+ function makeApi(over: Partial<ReferenceApi> = {}): ReferenceApi {
18
+ return {
19
+ search: jest.fn(async ({ query }) => [ada, bob].filter((t) => !query || t.label.toLowerCase().includes(query.toLowerCase()))),
20
+ resolve: jest.fn(async (_collections: string[], _key: string, keys: string[]) =>
21
+ keys.map((k) => ({ key: k, target: byKey[k] ?? null, ambiguous: false, candidates: [] })),
22
+ ),
23
+ referrers: jest.fn(async () => [{ id: "d2", collection: "articles", path: "content/articles/other.md", label: "Other", fieldPath: "related.0" }]),
24
+ ...over,
25
+ };
26
+ }
27
+
28
+ // The editor keeps unsaved edits per document id in localStorage, and every
29
+ // test here renders document "a": clear it so one test's pick never seeds the
30
+ // next test's field.
31
+ beforeEach(() => {
32
+ window.localStorage.clear();
33
+ });
34
+
35
+ const sections: CmsNavSection[] = [
36
+ { title: "Content", items: [{ key: "articles", label: "Articles", icon: "newspaper" }] },
37
+ ];
38
+
39
+ const authorDef: ReferenceDef = { collections: ["authors"], key: "_path", keyName: "ref", embed: [], onDelete: "restrict" };
40
+ const embedDef: ReferenceDef = {
41
+ collections: ["authors"], key: "_path", keyName: "ref", onDelete: "restrict",
42
+ embed: [{ name: "name", source: "name" }, { name: "url", source: "/authors/{{slug}}/" }],
43
+ };
44
+
45
+ // `null` renders without a references seam; the default is a working one.
46
+ // (A default parameter also fires for an explicit `undefined`, so the no-seam
47
+ // case has to be spelled `null`.)
48
+ function renderWith(fields: EntryField[], api: ReferenceApi | null = makeApi()) {
49
+ const onSave = jest.fn();
50
+ const entry: CmsEntry = { id: "a", path: "content/articles/a.md", title: "Alpha", body: "", fields };
51
+ render(
52
+ <ThemeProvider>
53
+ <ContentBrowser
54
+ workspace={{ name: "acme/site", initials: "AC", branch: "main", changed: 0 }}
55
+ sections={sections}
56
+ activeNavKey="articles"
57
+ onSelectNav={() => {}}
58
+ entries={[entry]}
59
+ userInitials="ED"
60
+ references={api ?? undefined}
61
+ onSaveEntry={onSave}
62
+ />
63
+ </ThemeProvider>,
64
+ );
65
+ return onSave;
66
+ }
67
+
68
+ test("a string reference resolves its key to the target's label", async () => {
69
+ const api = makeApi();
70
+ renderWith([{ name: "author", label: "Author", type: "string", component: "reference", reference: authorDef, value: ada.key }], api);
71
+ expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
72
+ expect(screen.getByTestId("reference-key")).toHaveTextContent(ada.key);
73
+ expect(api.resolve).toHaveBeenCalledWith(["authors"], "_path", [ada.key]);
74
+ });
75
+
76
+ test("a key naming nothing shows the missing state and keeps the key", async () => {
77
+ renderWith([{ name: "author", label: "Author", type: "string", component: "reference", reference: authorDef, value: "content/authors/gone.md" }]);
78
+ expect(await screen.findByTestId("reference-missing")).toBeInTheDocument();
79
+ expect(screen.getByTestId("reference-key")).toHaveTextContent("content/authors/gone.md");
80
+ });
81
+
82
+ test("picking a document from the picker writes its key", async () => {
83
+ const api = makeApi();
84
+ renderWith([{ name: "author", label: "Author", type: "string", component: "reference", reference: authorDef, value: "" }], api);
85
+ expect(screen.getByText("No document selected")).toBeInTheDocument();
86
+
87
+ fireEvent.click(screen.getByTestId("reference-choose"));
88
+ fireEvent.click(await screen.findByTestId(`reference-row-${bob.id}`));
89
+
90
+ // The control now holds Bob's key and resolves it.
91
+ expect(await screen.findByText("Bob")).toBeInTheDocument();
92
+ expect(screen.getByTestId("reference-key")).toHaveTextContent(bob.key);
93
+ expect(api.search).toHaveBeenCalledWith(expect.objectContaining({ collections: ["authors"], key: "_path" }));
94
+ });
95
+
96
+ test("an object reference shows its embedded copies read-only and picks by key child", async () => {
97
+ renderWith([
98
+ {
99
+ name: "author", label: "Author", type: "object", component: "reference", reference: embedDef,
100
+ fields: [
101
+ { name: "ref", type: "string", value: undefined },
102
+ { name: "name", label: "Name", type: "string", value: undefined },
103
+ { name: "url", label: "URL", type: "string", value: undefined },
104
+ ],
105
+ value: { ref: ada.key, name: "Ada Lovelace", url: "/authors/ada/" },
106
+ },
107
+ ]);
108
+ expect(await screen.findByTestId("reference-target")).toHaveTextContent("Ada Lovelace");
109
+ const copies = screen.getByTestId("reference-copies");
110
+ expect(copies).toHaveTextContent("/authors/ada/");
111
+ expect(copies).toHaveTextContent("Copied from Ada Lovelace");
112
+ // No input is offered for a copy: the server owns them.
113
+ expect(screen.queryByDisplayValue("/authors/ada/")).not.toBeInTheDocument();
114
+
115
+ fireEvent.click(screen.getByTestId("reference-choose"));
116
+ fireEvent.click(await screen.findByTestId(`reference-row-${bob.id}`));
117
+ expect(screen.getByTestId("reference-key")).toHaveTextContent(bob.key);
118
+ // The old copies stay until the server replaces them on save; once the new
119
+ // key resolves the caption names the new target.
120
+ await waitFor(() => expect(screen.getByTestId("reference-copies")).toHaveTextContent("Copied from Bob"));
121
+ expect(screen.getByTestId("reference-copies")).toHaveTextContent("/authors/ada/");
122
+ });
123
+
124
+ test("an array of references renders a picker per item", async () => {
125
+ renderWith([
126
+ { name: "related", label: "Related", type: "array", of: "string", component: "list", reference: authorDef, value: [ada.key, bob.key] },
127
+ ]);
128
+ expect(await screen.findAllByTestId("reference-choose")).toHaveLength(2);
129
+ expect(await screen.findByText("Bob")).toBeInTheDocument();
130
+ });
131
+
132
+ test("the Details modal shows the document's metadata and its references both ways", async () => {
133
+ renderWith([
134
+ { name: "title", label: "Title", type: "string", value: "Alpha" },
135
+ { name: "author", label: "Author", type: "string", component: "reference", reference: authorDef, value: ada.key },
136
+ { name: "editor", label: "Editor", type: "string", component: "reference", reference: authorDef, value: "content/authors/gone.md" },
137
+ ]);
138
+ // Nothing about references sits in the document body itself.
139
+ await screen.findByText("Ada Lovelace");
140
+ expect(screen.queryByText("Referenced by")).not.toBeInTheDocument();
141
+
142
+ fireEvent.click(screen.getByTestId("detail-menu"));
143
+ fireEvent.click(screen.getByTestId("detail-details"));
144
+ const modal = await screen.findByTestId("document-details");
145
+ expect(modal).toHaveTextContent("content/articles/a.md");
146
+ expect(modal).toHaveTextContent("Articles");
147
+
148
+ const refs = await screen.findByTestId("details-references");
149
+ await waitFor(() => expect(refs).toHaveTextContent("Ada Lovelace"));
150
+ expect(refs).toHaveTextContent("author");
151
+ expect(refs).toHaveTextContent("content/authors/gone.md — missing");
152
+
153
+ const referrers = await screen.findByTestId("details-referrers");
154
+ await waitFor(() => expect(referrers).toHaveTextContent("Other"));
155
+ expect(referrers).toHaveTextContent("related.0");
156
+
157
+ fireEvent.click(screen.getByTestId("details-close"));
158
+ expect(screen.queryByTestId("document-details")).not.toBeInTheDocument();
159
+ });
160
+
161
+ test("without a references seam the field renders read-only", async () => {
162
+ renderWith([{ name: "author", label: "Author", type: "string", component: "reference", reference: authorDef, value: ada.key }], null);
163
+ // Nothing resolves and the picker cannot open: the key shows raw.
164
+ expect(await screen.findByTestId("reference-key")).toHaveTextContent(ada.key);
165
+ fireEvent.click(screen.getByTestId("reference-choose"));
166
+ await new Promise((r) => setTimeout(r, 50));
167
+ expect(screen.queryByText("Choose a document")).not.toBeInTheDocument();
168
+ expect(screen.queryByTestId("reference-target")).not.toBeInTheDocument();
169
+ expect(screen.queryByTestId("reference-missing")).not.toBeInTheDocument();
170
+
171
+ // Details still opens — it is a read — with the metadata and the raw key,
172
+ // but claims nothing about what the key resolves to.
173
+ fireEvent.click(screen.getByTestId("detail-menu"));
174
+ fireEvent.click(screen.getByTestId("detail-details"));
175
+ const modal = await screen.findByTestId("document-details");
176
+ expect(modal).toHaveTextContent("content/articles/a.md");
177
+ expect(screen.getByTestId("details-references")).toHaveTextContent(ada.key);
178
+ expect(screen.queryByTestId("details-referrers")).not.toBeInTheDocument();
179
+ });
@@ -32,6 +32,9 @@ import {
32
32
  } from "./CollabField";
33
33
  import { clamp, reorder, slotAtX } from "./reorder";
34
34
  import { MediaField, MediaProvider, DocumentPathProvider, useFieldMedia } from "./MediaField";
35
+ import { ReferenceField, ReferenceProvider } from "./ReferenceField";
36
+ import { DocumentDetails } from "./DocumentDetails";
37
+ import type { ReferenceApi, ReferenceDef } from "../references";
35
38
  import { MediaBrowser } from "./MediaBrowser";
36
39
  import { FormsBrowser } from "./FormsBrowser";
37
40
  import { FORMS_NAV_KEY, formsNavKey, isFormsNavKey, parseFormsNavKey, type FormsApi } from "../forms";
@@ -76,6 +79,12 @@ export type EntryField = {
76
79
  // field these describe its *items*, making each one a media picker.
77
80
  media?: string;
78
81
  storeAs?: string;
82
+ // For a document picker (`component: "reference"`, or an array whose items
83
+ // are references): what the field points at and stores. An object-shaped
84
+ // reference's `fields` are the derived children — the key child and the
85
+ // embedded copies — which the server recomputes on every save and the
86
+ // editor shows read-only (docs/references.md).
87
+ reference?: ReferenceDef;
79
88
  value: unknown;
80
89
  // Item type for an array field (string | number | boolean | object).
81
90
  of?: string;
@@ -360,6 +369,11 @@ export type ContentBrowserProps = {
360
369
  // store gets.
361
370
  media?: MediaApi;
362
371
 
372
+ // References data seam, the same split (docs/references.md §6): the DS owns
373
+ // the document picker and the "Referenced by" list, the host owns fetching.
374
+ // Omitted → reference fields render read-only.
375
+ references?: ReferenceApi;
376
+
363
377
  // Forms data seam, the same split (docs/forms.md §10.2). Omitted → the Forms
364
378
  // surface never renders, which is what a config declaring no forms looks like.
365
379
  forms?: FormsApi;
@@ -878,6 +892,28 @@ function FieldControlInner(props: FieldControlProps) {
878
892
 
879
893
  const comp = resolveComponent(field);
880
894
 
895
+ // ---- references ----------------------------------------------------------
896
+ // Like media, one component for two field types: a string stores the key, an
897
+ // object stores the key plus the server-computed copies. Checked before the
898
+ // type branches for the same reason media is.
899
+ if (comp === "reference" && field.reference) {
900
+ return (
901
+ <View style={{ gap: t.space(2) }}>
902
+ {labelNode}
903
+ <ReferenceField
904
+ value={value}
905
+ onChange={onChange}
906
+ shape={field.type === "object" ? "object" : "string"}
907
+ def={field.reference}
908
+ embedFields={field.fields}
909
+ readOnly={readOnly}
910
+ error={!!error}
911
+ />
912
+ {errorNode}
913
+ </View>
914
+ );
915
+ }
916
+
881
917
  // ---- media ------------------------------------------------------------
882
918
  // Checked before the type-specific branches because `media` is the one
883
919
  // component valid for two different field types: a string field stores a path,
@@ -1681,6 +1717,9 @@ function ListControl({
1681
1717
  fields: field.fields,
1682
1718
  value: null,
1683
1719
  ...(field.media ? { component: "media", media: field.media, storeAs: field.storeAs } : {}),
1720
+ // An array that declares a reference makes each item a document picker, the
1721
+ // same way an array naming a media set makes each item a media picker.
1722
+ ...(field.reference ? { component: "reference", reference: field.reference } : {}),
1684
1723
  };
1685
1724
 
1686
1725
  // The list's own path, from the FieldControl wrapping this control; an item
@@ -2503,14 +2542,18 @@ function PromptDialog({
2503
2542
  );
2504
2543
  }
2505
2544
 
2506
- // DetailMenu is the "..." menu in the detail header: Rename / Move to folder /
2507
- // Delete, each shown only when its handler is provided. Delete has an inline
2508
- // confirm step; Rename/Move open a PromptDialog owned by EntryDetail.
2545
+ // DetailMenu is the "..." menu in the detail header: Details / Rename / Move to
2546
+ // folder / Delete, each shown only when its handler is provided. Details is a
2547
+ // read (metadata and references, see DocumentDetails) and so is offered on
2548
+ // read-only documents too; Delete has an inline confirm step; Rename/Move open
2549
+ // a PromptDialog owned by EntryDetail.
2509
2550
  function DetailMenu({
2551
+ onDetails,
2510
2552
  onRename,
2511
2553
  onMove,
2512
2554
  onDelete,
2513
2555
  }: {
2556
+ onDetails?: () => void;
2514
2557
  onRename?: () => void;
2515
2558
  onMove?: () => void;
2516
2559
  onDelete?: () => void;
@@ -2541,6 +2584,12 @@ function DetailMenu({
2541
2584
  borderRadius: t.radius.md, paddingVertical: t.space(1),
2542
2585
  }}
2543
2586
  >
2587
+ {onDetails ? (
2588
+ <Pressable testID="detail-details" onPress={() => { close(); onDetails(); }} style={rowStyle}>
2589
+ <Icon name="fileText" size={15} color={t.color.textSecondary} />
2590
+ <Text variant="sm">Details</Text>
2591
+ </Pressable>
2592
+ ) : null}
2544
2593
  {onRename ? (
2545
2594
  <Pressable testID="detail-rename" onPress={() => { close(); onRename(); }} style={rowStyle}>
2546
2595
  <Icon name="file" size={15} color={t.color.textSecondary} />
@@ -2684,6 +2733,7 @@ function EntryDetail({
2684
2733
  onDeleteEntry,
2685
2734
  onRename,
2686
2735
  onMove,
2736
+ collection,
2687
2737
  collectionPath,
2688
2738
  folderOptions,
2689
2739
  history,
@@ -2708,6 +2758,8 @@ function EntryDetail({
2708
2758
  onDeleteEntry?: (entry: CmsEntry) => Promise<void> | void;
2709
2759
  onRename?: (entry: CmsEntry, filename: string) => Promise<void> | void;
2710
2760
  onMove?: (entry: CmsEntry, folder: string) => Promise<void> | void;
2761
+ // The collection the document belongs to, shown in its Details.
2762
+ collection?: string;
2711
2763
  collectionPath?: string;
2712
2764
  // Existing folders (relative to the glob base) offered by the move autocomplete.
2713
2765
  folderOptions?: string[];
@@ -2735,6 +2787,19 @@ function EntryDetail({
2735
2787
  const canRename = !readOnly && !!onRename;
2736
2788
  const canMove = !readOnly && !!onMove && glob.hierarchical;
2737
2789
  const [prompt, setPrompt] = useState<"rename" | "move" | null>(null);
2790
+ // The Details modal: metadata and references, opened from the menu.
2791
+ const [showDetails, setShowDetails] = useState(false);
2792
+ // A header action that failed — a delete refused because other documents
2793
+ // reference this one — reported inline, where the Delete was pressed.
2794
+ const [actionError, setActionError] = useState<string | null>(null);
2795
+ const deleteEntry = onDeleteEntry
2796
+ ? () => {
2797
+ setActionError(null);
2798
+ Promise.resolve(onDeleteEntry(entry)).catch((e: unknown) => {
2799
+ setActionError(e instanceof Error ? e.message : "Couldn’t delete the document.");
2800
+ });
2801
+ }
2802
+ : undefined;
2738
2803
  // Per-field "your value was replaced" counters, bumped by a restore. Only the
2739
2804
  // host-supplied body editor reads them (see FieldSlotArgs.resetNonce); every
2740
2805
  // built-in control is controlled and needs no telling.
@@ -3191,13 +3256,12 @@ function EntryDetail({
3191
3256
  testID="document-history-toggle"
3192
3257
  />
3193
3258
  ) : null}
3194
- {canRename || canMove || (canDelete && onDeleteEntry) ? (
3195
- <DetailMenu
3196
- onRename={canRename ? () => setPrompt("rename") : undefined}
3197
- onMove={canMove ? () => setPrompt("move") : undefined}
3198
- onDelete={canDelete && onDeleteEntry ? () => onDeleteEntry(entry) : undefined}
3199
- />
3200
- ) : null}
3259
+ <DetailMenu
3260
+ onDetails={() => setShowDetails(true)}
3261
+ onRename={canRename ? () => setPrompt("rename") : undefined}
3262
+ onMove={canMove ? () => setPrompt("move") : undefined}
3263
+ onDelete={canDelete && deleteEntry ? deleteEntry : undefined}
3264
+ />
3201
3265
  {onCollapse ? (
3202
3266
  <IconButton name="panelLeft" size="sm" label="Collapse column" onPress={onCollapse} testID="column-collapse" />
3203
3267
  ) : null}
@@ -3247,6 +3311,7 @@ function EntryDetail({
3247
3311
  contentContainerStyle={{ padding: t.space(5), gap: t.space(4) }}
3248
3312
  >
3249
3313
  {saveHandler && error ? <FormAlert message={error} onDismiss={clearError} /> : null}
3314
+ {actionError ? <FormAlert message={actionError} onDismiss={() => setActionError(null)} /> : null}
3250
3315
  {groupPath.length > 0 ? (
3251
3316
  <Pressable
3252
3317
  testID="group-back"
@@ -3286,6 +3351,7 @@ function EntryDetail({
3286
3351
  <DocumentPathProvider path={entry.path}>
3287
3352
  <View key={entry.id} style={{ padding: t.space(5), gap: t.space(4), flex: 1 }}>
3288
3353
  {saveHandler && error ? <FormAlert message={error} onDismiss={clearError} /> : null}
3354
+ {actionError ? <FormAlert message={actionError} onDismiss={() => setActionError(null)} /> : null}
3289
3355
  <View style={{ gap: t.space(2) }}>
3290
3356
  <Text variant="label" color="tertiary">Title</Text>
3291
3357
  <Text variant="h2">{entry.title}</Text>
@@ -3300,6 +3366,9 @@ function EntryDetail({
3300
3366
  </DocumentPathProvider>
3301
3367
  )}
3302
3368
 
3369
+ {showDetails ? (
3370
+ <DocumentDetails entry={entry} collection={collection} onClose={() => setShowDetails(false)} />
3371
+ ) : null}
3303
3372
  {prompt === "rename" && onRename ? (
3304
3373
  <PromptDialog
3305
3374
  title="Rename file"
@@ -3402,6 +3471,8 @@ type ColumnModel = {
3402
3471
  canDelete?: boolean;
3403
3472
  canRename: boolean;
3404
3473
  canMove: boolean;
3474
+ // The tab's collection key, for the document's Details.
3475
+ collection?: string;
3405
3476
  collectionPath?: string;
3406
3477
  /** Set for a custom column (see OpenTab.content); `entry` is then unused. */
3407
3478
  content?: React.ReactNode;
@@ -4085,6 +4156,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4085
4156
  canDelete: tab.canDelete,
4086
4157
  canRename: !!tab.canRename,
4087
4158
  canMove: !!tab.canMove,
4159
+ collection: tab.collection,
4088
4160
  collectionPath: tab.collectionPath,
4089
4161
  content: tab.content,
4090
4162
  title: tab.title,
@@ -4276,6 +4348,9 @@ function DesktopBrowser(props: ContentBrowserProps) {
4276
4348
  onDeleteEntry={onDeleteEntry}
4277
4349
  onRename={col.canRename ? onRename : undefined}
4278
4350
  onMove={col.canMove ? onMove : undefined}
4351
+ // A host-managed tab names its collection; a column the browser opened
4352
+ // itself (uncontrolled selection) belongs to the active one.
4353
+ collection={navLabelFor(props.sections, col.collection || props.activeNavKey) ?? (col.collection || props.activeNavKey)}
4279
4354
  collectionPath={col.collectionPath}
4280
4355
  folderOptions={moveFolders}
4281
4356
  active={!showCreate && col.active}
@@ -5153,6 +5228,7 @@ function MobileBrowser(props: ContentBrowserProps) {
5153
5228
  onDeleteEntry={onDeleteEntry}
5154
5229
  onRename={onRename}
5155
5230
  onMove={onMove}
5231
+ collection={navLabelFor(props.sections, props.activeNavKey) ?? props.activeNavKey}
5156
5232
  collectionPath={collectionPath}
5157
5233
  folderOptions={moveFolders}
5158
5234
  />
@@ -5404,7 +5480,9 @@ export function ContentBrowser(props: ContentBrowserProps) {
5404
5480
  // object store) renders media fields read-only.
5405
5481
  return (
5406
5482
  <MediaProvider api={props.media}>
5407
- {isDesktop ? <DesktopBrowser {...withSections} /> : <MobileBrowser {...withSections} />}
5483
+ <ReferenceProvider api={props.references}>
5484
+ {isDesktop ? <DesktopBrowser {...withSections} /> : <MobileBrowser {...withSections} />}
5485
+ </ReferenceProvider>
5408
5486
  </MediaProvider>
5409
5487
  );
5410
5488
  }
@@ -7,12 +7,12 @@
7
7
  // valid for both string (ISO-8601 text) and number (epoch).
8
8
 
9
9
  export const componentsByType: Record<string, string[]> = {
10
- string: ["input", "textarea", "body", "slug", "email", "url", "date", "datetime", "select", "media"],
10
+ string: ["input", "textarea", "body", "slug", "email", "url", "date", "datetime", "select", "media", "reference"],
11
11
  boolean: ["checkbox", "switch"],
12
12
  integer: ["number", "stepper", "telephone", "date", "datetime", "select"],
13
13
  float: ["number", "stepper", "telephone", "date", "datetime", "select"],
14
14
  array: ["list", "mixedList"],
15
- object: ["inlineGroup", "group", "media"],
15
+ object: ["inlineGroup", "group", "media", "reference"],
16
16
  };
17
17
 
18
18
  export const defaultComponentByType: Record<string, string> = {
package/src/index.ts CHANGED
@@ -172,6 +172,21 @@ export type {
172
172
  MediaFacets,
173
173
  } from "./media";
174
174
 
175
+ // References: the document picker, "referenced by", and the host data seam
176
+ export { ReferenceField, ReferencePicker, ReferenceProvider, useReferenceApi } from "./components/ReferenceField";
177
+ export type { ReferenceFieldProps, ReferencePickerProps } from "./components/ReferenceField";
178
+ export { DocumentDetails, collectSites } from "./components/DocumentDetails";
179
+ export type { DocumentDetailsProps, DetailsField } from "./components/DocumentDetails";
180
+ export { DEFAULT_REFERENCE_KEY_NAME, referenceKey, referenceValue } from "./references";
181
+ export type {
182
+ ReferenceApi,
183
+ ReferenceDef,
184
+ ReferenceEmbed,
185
+ ReferenceTarget,
186
+ ReferenceResolution,
187
+ Referrer,
188
+ } from "./references";
189
+
175
190
  // Field components registry (mirror of the Go schema's component set)
176
191
  export {
177
192
  componentsByType,
@@ -0,0 +1,97 @@
1
+ // The document picker's declaration and its host data seam.
2
+ //
3
+ // This is the TypeScript mirror of packages/importer/schema/references.go — the
4
+ // Go file is the source of truth validated on config import; keep the two in
5
+ // sync. A reference field points at documents of other collections (or its
6
+ // own). A string field stores the target's key; an object field stores the key
7
+ // under `keyName` plus embedded copies the SERVER computes on every save — the
8
+ // editor shows those copies read-only and never writes them
9
+ // (docs/references.md).
10
+
11
+ // ReferenceDef is a field's `reference:` block as the API delivers it.
12
+ export interface ReferenceDef {
13
+ // The collections the field may point at.
14
+ collections: string[];
15
+ // Which rendered form of the target the field stores: "_path" (the target's
16
+ // project-relative path) or the name of a field on the target.
17
+ key: string;
18
+ // For the object shape, the child that holds the key ("ref" unless renamed).
19
+ keyName: string;
20
+ // The embedded copies, in the order the derived children appear.
21
+ embed: ReferenceEmbed[];
22
+ // What deleting a referenced document does: restrict | unset.
23
+ onDelete: string;
24
+ }
25
+
26
+ export interface ReferenceEmbed {
27
+ name: string;
28
+ source: string;
29
+ }
30
+
31
+ // ReferenceTarget is one document a reference can point at, keyed as the field
32
+ // stores it. `path` is repository-relative, like a document's.
33
+ export interface ReferenceTarget {
34
+ id: string;
35
+ collection: string;
36
+ path: string;
37
+ key: string;
38
+ label: string;
39
+ }
40
+
41
+ // ReferenceResolution is the answer to "what does this stored key name?".
42
+ // `target` null with `ambiguous` false is a dangling reference; `ambiguous`
43
+ // means a field key several documents share, and `candidates` holds them.
44
+ export interface ReferenceResolution {
45
+ key: string;
46
+ target?: ReferenceTarget | null;
47
+ ambiguous: boolean;
48
+ candidates: ReferenceTarget[];
49
+ }
50
+
51
+ // Referrer is one document that points at another, and the field it does so
52
+ // from.
53
+ export interface Referrer {
54
+ id: string;
55
+ collection: string;
56
+ path: string;
57
+ label: string;
58
+ fieldPath: string;
59
+ }
60
+
61
+ // ReferenceApi is the data seam between the design system and its host, the
62
+ // same split as MediaApi: the DS owns the picker and the display, the host owns
63
+ // fetching. Omitting it from ContentBrowser renders reference fields read-only.
64
+ export interface ReferenceApi {
65
+ // Documents the field may point at — the picker's list. `collections` and
66
+ // `key` are the field's ReferenceDef; `query` matches labels.
67
+ search: (params: { collections: string[]; key: string; query?: string; limit?: number }) => Promise<ReferenceTarget[]>;
68
+ // Resolve stored keys to their documents, batched per document.
69
+ resolve: (collections: string[], key: string, keys: string[]) => Promise<ReferenceResolution[]>;
70
+ // Documents that point at one — "Referenced by". Omit to hide the section.
71
+ referrers?: (documentId: string) => Promise<Referrer[]>;
72
+ }
73
+
74
+ // The key child an object reference stores by default. Mirrors the Go
75
+ // DefaultKeyName.
76
+ export const DEFAULT_REFERENCE_KEY_NAME = "ref";
77
+
78
+ // referenceKey reads the stored key out of a reference field's value, for
79
+ // either shape. Empty when the field is unset.
80
+ export function referenceKey(value: unknown, shape: "string" | "object", def: ReferenceDef): string {
81
+ if (shape === "object") {
82
+ if (typeof value !== "object" || value === null) return "";
83
+ const key = (value as Record<string, unknown>)[def.keyName || DEFAULT_REFERENCE_KEY_NAME];
84
+ return typeof key === "string" ? key.trim() : "";
85
+ }
86
+ return typeof value === "string" ? value.trim() : "";
87
+ }
88
+
89
+ // referenceValue builds the value the field writes when a target is picked:
90
+ // the key alone in a string field; in an object field the key under keyName,
91
+ // with any copies the value already carried kept until the server replaces
92
+ // them on save — writing nothing else is what keeps the copies the server's.
93
+ export function referenceValue(target: ReferenceTarget, shape: "string" | "object", def: ReferenceDef, previous: unknown): unknown {
94
+ if (shape === "string") return target.key;
95
+ const prev = typeof previous === "object" && previous !== null ? (previous as Record<string, unknown>) : {};
96
+ return { ...prev, [def.keyName || DEFAULT_REFERENCE_KEY_NAME]: target.key };
97
+ }