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

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.12",
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,260 @@
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`.) `extra` is spread over the browser's props —
48
+ // the host's open-document callback, for the tests that need one.
49
+ function renderWith(
50
+ fields: EntryField[],
51
+ api: ReferenceApi | null = makeApi(),
52
+ extra: Partial<React.ComponentProps<typeof ContentBrowser>> = {},
53
+ ) {
54
+ const onSave = jest.fn();
55
+ const entry: CmsEntry = { id: "a", path: "content/articles/a.md", title: "Alpha", body: "", fields };
56
+ render(
57
+ <ThemeProvider>
58
+ <ContentBrowser
59
+ workspace={{ name: "acme/site", initials: "AC", branch: "main", changed: 0 }}
60
+ sections={sections}
61
+ activeNavKey="articles"
62
+ onSelectNav={() => {}}
63
+ entries={[entry]}
64
+ userInitials="ED"
65
+ references={api ?? undefined}
66
+ onSaveEntry={onSave}
67
+ {...extra}
68
+ />
69
+ </ThemeProvider>,
70
+ );
71
+ return onSave;
72
+ }
73
+
74
+ test("a string reference resolves its key to the target's label", async () => {
75
+ const api = makeApi();
76
+ renderWith([{ name: "author", label: "Author", type: "string", component: "reference", reference: authorDef, value: ada.key }], api);
77
+ expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
78
+ expect(screen.getByTestId("reference-key")).toHaveTextContent(ada.key);
79
+ expect(api.resolve).toHaveBeenCalledWith(["authors"], "_path", [ada.key]);
80
+ });
81
+
82
+ test("a key naming nothing shows the missing state and keeps the key", async () => {
83
+ renderWith([{ name: "author", label: "Author", type: "string", component: "reference", reference: authorDef, value: "content/authors/gone.md" }]);
84
+ expect(await screen.findByTestId("reference-missing")).toBeInTheDocument();
85
+ expect(screen.getByTestId("reference-key")).toHaveTextContent("content/authors/gone.md");
86
+ });
87
+
88
+ test("picking a document from the picker writes its key", async () => {
89
+ const api = makeApi();
90
+ renderWith([{ name: "author", label: "Author", type: "string", component: "reference", reference: authorDef, value: "" }], api);
91
+ expect(screen.getByText("No document selected")).toBeInTheDocument();
92
+
93
+ fireEvent.click(screen.getByTestId("reference-choose"));
94
+ fireEvent.click(await screen.findByTestId(`reference-row-${bob.id}`));
95
+
96
+ // The control now holds Bob's key and resolves it.
97
+ expect(await screen.findByText("Bob")).toBeInTheDocument();
98
+ expect(screen.getByTestId("reference-key")).toHaveTextContent(bob.key);
99
+ expect(api.search).toHaveBeenCalledWith(expect.objectContaining({ collections: ["authors"], key: "_path" }));
100
+ });
101
+
102
+ test("an object reference shows its embedded copies read-only and picks by key child", async () => {
103
+ renderWith([
104
+ {
105
+ name: "author", label: "Author", type: "object", component: "reference", reference: embedDef,
106
+ fields: [
107
+ { name: "ref", type: "string", value: undefined },
108
+ { name: "name", label: "Name", type: "string", value: undefined },
109
+ { name: "url", label: "URL", type: "string", value: undefined },
110
+ ],
111
+ value: { ref: ada.key, name: "Ada Lovelace", url: "/authors/ada/" },
112
+ },
113
+ ]);
114
+ expect(await screen.findByTestId("reference-target")).toHaveTextContent("Ada Lovelace");
115
+ const copies = screen.getByTestId("reference-copies");
116
+ expect(copies).toHaveTextContent("/authors/ada/");
117
+ expect(copies).toHaveTextContent("Copied from Ada Lovelace");
118
+ // No input is offered for a copy: the server owns them.
119
+ expect(screen.queryByDisplayValue("/authors/ada/")).not.toBeInTheDocument();
120
+
121
+ fireEvent.click(screen.getByTestId("reference-choose"));
122
+ fireEvent.click(await screen.findByTestId(`reference-row-${bob.id}`));
123
+ expect(screen.getByTestId("reference-key")).toHaveTextContent(bob.key);
124
+ // The old copies stay until the server replaces them on save; once the new
125
+ // key resolves the caption names the new target.
126
+ await waitFor(() => expect(screen.getByTestId("reference-copies")).toHaveTextContent("Copied from Bob"));
127
+ expect(screen.getByTestId("reference-copies")).toHaveTextContent("/authors/ada/");
128
+ });
129
+
130
+ test("an array of references renders a picker per item", async () => {
131
+ renderWith([
132
+ { name: "related", label: "Related", type: "array", of: "string", component: "list", reference: authorDef, value: [ada.key, bob.key] },
133
+ ]);
134
+ expect(await screen.findAllByTestId("reference-choose")).toHaveLength(2);
135
+ expect(await screen.findByText("Bob")).toBeInTheDocument();
136
+ });
137
+
138
+ test("Add item on a reference list opens the picker and appends the pick", async () => {
139
+ renderWith([
140
+ { name: "related", label: "Related", type: "array", of: "string", component: "list", reference: authorDef, value: [ada.key] },
141
+ ]);
142
+ expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
143
+
144
+ fireEvent.click(screen.getByTestId("list-add"));
145
+ // No empty slot appeared — the picker did — and the pick becomes the item.
146
+ expect(screen.getAllByTestId("reference-choose")).toHaveLength(1);
147
+ fireEvent.click(await screen.findByTestId(`reference-row-${bob.id}`));
148
+
149
+ expect(await screen.findByText("Bob")).toBeInTheDocument();
150
+ expect(screen.getAllByTestId("reference-choose")).toHaveLength(2);
151
+ expect(screen.getAllByTestId("reference-key").map((el) => el.textContent)).toEqual([ada.key, bob.key]);
152
+ });
153
+
154
+ test("Add item on a reference list without a seam is disabled rather than adding a slot", async () => {
155
+ renderWith(
156
+ [{ name: "related", label: "Related", type: "array", of: "string", component: "list", reference: authorDef, value: [] }],
157
+ null,
158
+ );
159
+ fireEvent.click(await screen.findByTestId("list-add"));
160
+ await new Promise((r) => setTimeout(r, 50));
161
+ expect(screen.queryByTestId("reference-choose")).not.toBeInTheDocument();
162
+ expect(screen.queryByText("Choose a document")).not.toBeInTheDocument();
163
+ });
164
+
165
+ test("a resolved target can be opened in a new pane, handing the document to the host", async () => {
166
+ const onOpenReference = jest.fn();
167
+ renderWith(
168
+ [{ name: "author", label: "Author", type: "string", component: "reference", reference: authorDef, value: ada.key }],
169
+ makeApi(),
170
+ { onOpenReference },
171
+ );
172
+ expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
173
+ fireEvent.click(screen.getByTestId("reference-open"));
174
+ expect(onOpenReference).toHaveBeenCalledWith(
175
+ expect.objectContaining({ id: ada.id, collection: "authors", path: ada.path, label: "Ada Lovelace" }),
176
+ );
177
+ });
178
+
179
+ test("without a host that opens documents the Open action is not offered", async () => {
180
+ renderWith([{ name: "author", label: "Author", type: "string", component: "reference", reference: authorDef, value: ada.key }]);
181
+ expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
182
+ expect(screen.queryByTestId("reference-open")).not.toBeInTheDocument();
183
+ });
184
+
185
+ test("the Details modal opens a reference or a referrer in a new pane and closes itself", async () => {
186
+ const onOpenReference = jest.fn();
187
+ renderWith(
188
+ [{ name: "author", label: "Author", type: "string", component: "reference", reference: authorDef, value: ada.key }],
189
+ makeApi(),
190
+ { onOpenReference },
191
+ );
192
+ await screen.findByText("Ada Lovelace");
193
+
194
+ fireEvent.click(screen.getByTestId("detail-menu"));
195
+ fireEvent.click(screen.getByTestId("detail-details"));
196
+ const refs = await screen.findByTestId("details-references");
197
+ await waitFor(() => expect(refs).toHaveTextContent("Ada Lovelace"));
198
+ fireEvent.click(screen.getByTestId(`details-open-${ada.id}`));
199
+ expect(onOpenReference).toHaveBeenLastCalledWith(expect.objectContaining({ id: ada.id, collection: "authors" }));
200
+ expect(screen.queryByTestId("document-details")).not.toBeInTheDocument();
201
+
202
+ fireEvent.click(screen.getByTestId("detail-menu"));
203
+ fireEvent.click(screen.getByTestId("detail-details"));
204
+ const referrers = await screen.findByTestId("details-referrers");
205
+ await waitFor(() => expect(referrers).toHaveTextContent("Other"));
206
+ fireEvent.click(screen.getByTestId("details-open-d2"));
207
+ expect(onOpenReference).toHaveBeenLastCalledWith(
208
+ expect.objectContaining({ id: "d2", collection: "articles", path: "content/articles/other.md", label: "Other" }),
209
+ );
210
+ expect(screen.queryByTestId("document-details")).not.toBeInTheDocument();
211
+ });
212
+
213
+ test("the Details modal shows the document's metadata and its references both ways", async () => {
214
+ renderWith([
215
+ { name: "title", label: "Title", type: "string", value: "Alpha" },
216
+ { name: "author", label: "Author", type: "string", component: "reference", reference: authorDef, value: ada.key },
217
+ { name: "editor", label: "Editor", type: "string", component: "reference", reference: authorDef, value: "content/authors/gone.md" },
218
+ ]);
219
+ // Nothing about references sits in the document body itself.
220
+ await screen.findByText("Ada Lovelace");
221
+ expect(screen.queryByText("Referenced by")).not.toBeInTheDocument();
222
+
223
+ fireEvent.click(screen.getByTestId("detail-menu"));
224
+ fireEvent.click(screen.getByTestId("detail-details"));
225
+ const modal = await screen.findByTestId("document-details");
226
+ expect(modal).toHaveTextContent("content/articles/a.md");
227
+ expect(modal).toHaveTextContent("Articles");
228
+
229
+ const refs = await screen.findByTestId("details-references");
230
+ await waitFor(() => expect(refs).toHaveTextContent("Ada Lovelace"));
231
+ expect(refs).toHaveTextContent("author");
232
+ expect(refs).toHaveTextContent("content/authors/gone.md — missing");
233
+
234
+ const referrers = await screen.findByTestId("details-referrers");
235
+ await waitFor(() => expect(referrers).toHaveTextContent("Other"));
236
+ expect(referrers).toHaveTextContent("related.0");
237
+
238
+ fireEvent.click(screen.getByTestId("details-close"));
239
+ expect(screen.queryByTestId("document-details")).not.toBeInTheDocument();
240
+ });
241
+
242
+ test("without a references seam the field renders read-only", async () => {
243
+ renderWith([{ name: "author", label: "Author", type: "string", component: "reference", reference: authorDef, value: ada.key }], null);
244
+ // Nothing resolves and the picker cannot open: the key shows raw.
245
+ expect(await screen.findByTestId("reference-key")).toHaveTextContent(ada.key);
246
+ fireEvent.click(screen.getByTestId("reference-choose"));
247
+ await new Promise((r) => setTimeout(r, 50));
248
+ expect(screen.queryByText("Choose a document")).not.toBeInTheDocument();
249
+ expect(screen.queryByTestId("reference-target")).not.toBeInTheDocument();
250
+ expect(screen.queryByTestId("reference-missing")).not.toBeInTheDocument();
251
+
252
+ // Details still opens — it is a read — with the metadata and the raw key,
253
+ // but claims nothing about what the key resolves to.
254
+ fireEvent.click(screen.getByTestId("detail-menu"));
255
+ fireEvent.click(screen.getByTestId("detail-details"));
256
+ const modal = await screen.findByTestId("document-details");
257
+ expect(modal).toHaveTextContent("content/articles/a.md");
258
+ expect(screen.getByTestId("details-references")).toHaveTextContent(ada.key);
259
+ expect(screen.queryByTestId("details-referrers")).not.toBeInTheDocument();
260
+ });
@@ -32,6 +32,10 @@ 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, ReferencePicker, ReferenceProvider, useReferenceApi } from "./ReferenceField";
36
+ import { DocumentDetails } from "./DocumentDetails";
37
+ import type { ReferenceApi, ReferenceDef, ReferenceDocument, ReferenceTarget } from "../references";
38
+ import { referenceValue } from "../references";
35
39
  import { MediaBrowser } from "./MediaBrowser";
36
40
  import { FormsBrowser } from "./FormsBrowser";
37
41
  import { FORMS_NAV_KEY, formsNavKey, isFormsNavKey, parseFormsNavKey, type FormsApi } from "../forms";
@@ -76,6 +80,12 @@ export type EntryField = {
76
80
  // field these describe its *items*, making each one a media picker.
77
81
  media?: string;
78
82
  storeAs?: string;
83
+ // For a document picker (`component: "reference"`, or an array whose items
84
+ // are references): what the field points at and stores. An object-shaped
85
+ // reference's `fields` are the derived children — the key child and the
86
+ // embedded copies — which the server recomputes on every save and the
87
+ // editor shows read-only (docs/references.md).
88
+ reference?: ReferenceDef;
79
89
  value: unknown;
80
90
  // Item type for an array field (string | number | boolean | object).
81
91
  of?: string;
@@ -360,6 +370,17 @@ export type ContentBrowserProps = {
360
370
  // store gets.
361
371
  media?: MediaApi;
362
372
 
373
+ // References data seam, the same split (docs/references.md §6): the DS owns
374
+ // the document picker and the "Referenced by" list, the host owns fetching.
375
+ // Omitted → reference fields render read-only.
376
+ references?: ReferenceApi;
377
+ // Open a referenced document — the Open action on a reference field's target
378
+ // and on the rows of the Details modal. The document may belong to any
379
+ // collection, so the host, which owns the open columns and the URL, does the
380
+ // opening (a new column on desktop, in place on mobile). Omitted, the action
381
+ // is not offered.
382
+ onOpenReference?: (doc: ReferenceDocument) => void;
383
+
363
384
  // Forms data seam, the same split (docs/forms.md §10.2). Omitted → the Forms
364
385
  // surface never renders, which is what a config declaring no forms looks like.
365
386
  forms?: FormsApi;
@@ -878,6 +899,28 @@ function FieldControlInner(props: FieldControlProps) {
878
899
 
879
900
  const comp = resolveComponent(field);
880
901
 
902
+ // ---- references ----------------------------------------------------------
903
+ // Like media, one component for two field types: a string stores the key, an
904
+ // object stores the key plus the server-computed copies. Checked before the
905
+ // type branches for the same reason media is.
906
+ if (comp === "reference" && field.reference) {
907
+ return (
908
+ <View style={{ gap: t.space(2) }}>
909
+ {labelNode}
910
+ <ReferenceField
911
+ value={value}
912
+ onChange={onChange}
913
+ shape={field.type === "object" ? "object" : "string"}
914
+ def={field.reference}
915
+ embedFields={field.fields}
916
+ readOnly={readOnly}
917
+ error={!!error}
918
+ />
919
+ {errorNode}
920
+ </View>
921
+ );
922
+ }
923
+
881
924
  // ---- media ------------------------------------------------------------
882
925
  // Checked before the type-specific branches because `media` is the one
883
926
  // component valid for two different field types: a string field stores a path,
@@ -1668,7 +1711,26 @@ function ListControl({
1668
1711
  [next[i], next[j]] = [next[j], next[i]];
1669
1712
  onChange(next);
1670
1713
  };
1671
- const addItem = () => onChange([...items, isObject ? {} : ""]);
1714
+ // A reference list's "Add item" opens the picker straight away and appends
1715
+ // what was picked: the only thing such an item can hold is a picked
1716
+ // document, so an empty slot to fill in afterwards is a step nobody wants.
1717
+ // Without a host seam there is nothing to pick from, and the button is
1718
+ // disabled as the field's own Choose button is.
1719
+ const reference = field.reference;
1720
+ const referenceApi = useReferenceApi();
1721
+ const [pickingNew, setPickingNew] = React.useState(false);
1722
+ const addItem = () => {
1723
+ if (reference) {
1724
+ if (referenceApi) setPickingNew(true);
1725
+ return;
1726
+ }
1727
+ onChange([...items, isObject ? {} : ""]);
1728
+ };
1729
+ const addPicked = (target: ReferenceTarget) => {
1730
+ setPickingNew(false);
1731
+ if (!reference) return;
1732
+ onChange([...items, referenceValue(target, isObject ? "object" : "string", reference, undefined)]);
1733
+ };
1672
1734
 
1673
1735
  // Items inherit the array's *item-level* display config, not the array's own
1674
1736
  // component — `list` describes the array, and is not a control an item could
@@ -1681,6 +1743,9 @@ function ListControl({
1681
1743
  fields: field.fields,
1682
1744
  value: null,
1683
1745
  ...(field.media ? { component: "media", media: field.media, storeAs: field.storeAs } : {}),
1746
+ // An array that declares a reference makes each item a document picker, the
1747
+ // same way an array naming a media set makes each item a media picker.
1748
+ ...(field.reference ? { component: "reference", reference: field.reference } : {}),
1684
1749
  };
1685
1750
 
1686
1751
  // The list's own path, from the FieldControl wrapping this control; an item
@@ -1695,9 +1760,20 @@ function ListControl({
1695
1760
  ))}
1696
1761
  {!readOnly && canAdd ? (
1697
1762
  <View style={{ alignSelf: "flex-start" }}>
1698
- <Button title="Add item" variant="default" size="sm" iconLeft="plus" onPress={addItem} testID="list-add" />
1763
+ <Button
1764
+ title="Add item"
1765
+ variant="default"
1766
+ size="sm"
1767
+ iconLeft="plus"
1768
+ onPress={addItem}
1769
+ disabled={!!reference && !referenceApi}
1770
+ testID="list-add"
1771
+ />
1699
1772
  </View>
1700
1773
  ) : null}
1774
+ {reference ? (
1775
+ <ReferencePicker visible={pickingNew} onClose={() => setPickingNew(false)} onSelect={addPicked} def={reference} />
1776
+ ) : null}
1701
1777
  </View>
1702
1778
  );
1703
1779
  }
@@ -2503,14 +2579,18 @@ function PromptDialog({
2503
2579
  );
2504
2580
  }
2505
2581
 
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.
2582
+ // DetailMenu is the "..." menu in the detail header: Details / Rename / Move to
2583
+ // folder / Delete, each shown only when its handler is provided. Details is a
2584
+ // read (metadata and references, see DocumentDetails) and so is offered on
2585
+ // read-only documents too; Delete has an inline confirm step; Rename/Move open
2586
+ // a PromptDialog owned by EntryDetail.
2509
2587
  function DetailMenu({
2588
+ onDetails,
2510
2589
  onRename,
2511
2590
  onMove,
2512
2591
  onDelete,
2513
2592
  }: {
2593
+ onDetails?: () => void;
2514
2594
  onRename?: () => void;
2515
2595
  onMove?: () => void;
2516
2596
  onDelete?: () => void;
@@ -2541,6 +2621,12 @@ function DetailMenu({
2541
2621
  borderRadius: t.radius.md, paddingVertical: t.space(1),
2542
2622
  }}
2543
2623
  >
2624
+ {onDetails ? (
2625
+ <Pressable testID="detail-details" onPress={() => { close(); onDetails(); }} style={rowStyle}>
2626
+ <Icon name="fileText" size={15} color={t.color.textSecondary} />
2627
+ <Text variant="sm">Details</Text>
2628
+ </Pressable>
2629
+ ) : null}
2544
2630
  {onRename ? (
2545
2631
  <Pressable testID="detail-rename" onPress={() => { close(); onRename(); }} style={rowStyle}>
2546
2632
  <Icon name="file" size={15} color={t.color.textSecondary} />
@@ -2684,6 +2770,7 @@ function EntryDetail({
2684
2770
  onDeleteEntry,
2685
2771
  onRename,
2686
2772
  onMove,
2773
+ collection,
2687
2774
  collectionPath,
2688
2775
  folderOptions,
2689
2776
  history,
@@ -2708,6 +2795,8 @@ function EntryDetail({
2708
2795
  onDeleteEntry?: (entry: CmsEntry) => Promise<void> | void;
2709
2796
  onRename?: (entry: CmsEntry, filename: string) => Promise<void> | void;
2710
2797
  onMove?: (entry: CmsEntry, folder: string) => Promise<void> | void;
2798
+ // The collection the document belongs to, shown in its Details.
2799
+ collection?: string;
2711
2800
  collectionPath?: string;
2712
2801
  // Existing folders (relative to the glob base) offered by the move autocomplete.
2713
2802
  folderOptions?: string[];
@@ -2735,6 +2824,19 @@ function EntryDetail({
2735
2824
  const canRename = !readOnly && !!onRename;
2736
2825
  const canMove = !readOnly && !!onMove && glob.hierarchical;
2737
2826
  const [prompt, setPrompt] = useState<"rename" | "move" | null>(null);
2827
+ // The Details modal: metadata and references, opened from the menu.
2828
+ const [showDetails, setShowDetails] = useState(false);
2829
+ // A header action that failed — a delete refused because other documents
2830
+ // reference this one — reported inline, where the Delete was pressed.
2831
+ const [actionError, setActionError] = useState<string | null>(null);
2832
+ const deleteEntry = onDeleteEntry
2833
+ ? () => {
2834
+ setActionError(null);
2835
+ Promise.resolve(onDeleteEntry(entry)).catch((e: unknown) => {
2836
+ setActionError(e instanceof Error ? e.message : "Couldn’t delete the document.");
2837
+ });
2838
+ }
2839
+ : undefined;
2738
2840
  // Per-field "your value was replaced" counters, bumped by a restore. Only the
2739
2841
  // host-supplied body editor reads them (see FieldSlotArgs.resetNonce); every
2740
2842
  // built-in control is controlled and needs no telling.
@@ -3191,13 +3293,12 @@ function EntryDetail({
3191
3293
  testID="document-history-toggle"
3192
3294
  />
3193
3295
  ) : 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}
3296
+ <DetailMenu
3297
+ onDetails={() => setShowDetails(true)}
3298
+ onRename={canRename ? () => setPrompt("rename") : undefined}
3299
+ onMove={canMove ? () => setPrompt("move") : undefined}
3300
+ onDelete={canDelete && deleteEntry ? deleteEntry : undefined}
3301
+ />
3201
3302
  {onCollapse ? (
3202
3303
  <IconButton name="panelLeft" size="sm" label="Collapse column" onPress={onCollapse} testID="column-collapse" />
3203
3304
  ) : null}
@@ -3247,6 +3348,7 @@ function EntryDetail({
3247
3348
  contentContainerStyle={{ padding: t.space(5), gap: t.space(4) }}
3248
3349
  >
3249
3350
  {saveHandler && error ? <FormAlert message={error} onDismiss={clearError} /> : null}
3351
+ {actionError ? <FormAlert message={actionError} onDismiss={() => setActionError(null)} /> : null}
3250
3352
  {groupPath.length > 0 ? (
3251
3353
  <Pressable
3252
3354
  testID="group-back"
@@ -3286,6 +3388,7 @@ function EntryDetail({
3286
3388
  <DocumentPathProvider path={entry.path}>
3287
3389
  <View key={entry.id} style={{ padding: t.space(5), gap: t.space(4), flex: 1 }}>
3288
3390
  {saveHandler && error ? <FormAlert message={error} onDismiss={clearError} /> : null}
3391
+ {actionError ? <FormAlert message={actionError} onDismiss={() => setActionError(null)} /> : null}
3289
3392
  <View style={{ gap: t.space(2) }}>
3290
3393
  <Text variant="label" color="tertiary">Title</Text>
3291
3394
  <Text variant="h2">{entry.title}</Text>
@@ -3300,6 +3403,9 @@ function EntryDetail({
3300
3403
  </DocumentPathProvider>
3301
3404
  )}
3302
3405
 
3406
+ {showDetails ? (
3407
+ <DocumentDetails entry={entry} collection={collection} onClose={() => setShowDetails(false)} />
3408
+ ) : null}
3303
3409
  {prompt === "rename" && onRename ? (
3304
3410
  <PromptDialog
3305
3411
  title="Rename file"
@@ -3402,6 +3508,8 @@ type ColumnModel = {
3402
3508
  canDelete?: boolean;
3403
3509
  canRename: boolean;
3404
3510
  canMove: boolean;
3511
+ // The tab's collection key, for the document's Details.
3512
+ collection?: string;
3405
3513
  collectionPath?: string;
3406
3514
  /** Set for a custom column (see OpenTab.content); `entry` is then unused. */
3407
3515
  content?: React.ReactNode;
@@ -4085,6 +4193,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4085
4193
  canDelete: tab.canDelete,
4086
4194
  canRename: !!tab.canRename,
4087
4195
  canMove: !!tab.canMove,
4196
+ collection: tab.collection,
4088
4197
  collectionPath: tab.collectionPath,
4089
4198
  content: tab.content,
4090
4199
  title: tab.title,
@@ -4276,6 +4385,9 @@ function DesktopBrowser(props: ContentBrowserProps) {
4276
4385
  onDeleteEntry={onDeleteEntry}
4277
4386
  onRename={col.canRename ? onRename : undefined}
4278
4387
  onMove={col.canMove ? onMove : undefined}
4388
+ // A host-managed tab names its collection; a column the browser opened
4389
+ // itself (uncontrolled selection) belongs to the active one.
4390
+ collection={navLabelFor(props.sections, col.collection || props.activeNavKey) ?? (col.collection || props.activeNavKey)}
4279
4391
  collectionPath={col.collectionPath}
4280
4392
  folderOptions={moveFolders}
4281
4393
  active={!showCreate && col.active}
@@ -5153,6 +5265,7 @@ function MobileBrowser(props: ContentBrowserProps) {
5153
5265
  onDeleteEntry={onDeleteEntry}
5154
5266
  onRename={onRename}
5155
5267
  onMove={onMove}
5268
+ collection={navLabelFor(props.sections, props.activeNavKey) ?? props.activeNavKey}
5156
5269
  collectionPath={collectionPath}
5157
5270
  folderOptions={moveFolders}
5158
5271
  />
@@ -5404,7 +5517,9 @@ export function ContentBrowser(props: ContentBrowserProps) {
5404
5517
  // object store) renders media fields read-only.
5405
5518
  return (
5406
5519
  <MediaProvider api={props.media}>
5407
- {isDesktop ? <DesktopBrowser {...withSections} /> : <MobileBrowser {...withSections} />}
5520
+ <ReferenceProvider api={props.references} onOpen={props.onOpenReference}>
5521
+ {isDesktop ? <DesktopBrowser {...withSections} /> : <MobileBrowser {...withSections} />}
5522
+ </ReferenceProvider>
5408
5523
  </MediaProvider>
5409
5524
  );
5410
5525
  }
@@ -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,22 @@ 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, useOpenReference } from "./components/ReferenceField";
177
+ export type { ReferenceFieldProps, ReferencePickerProps, OpenReference } 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
+ ReferenceDocument,
185
+ ReferenceEmbed,
186
+ ReferenceTarget,
187
+ ReferenceResolution,
188
+ Referrer,
189
+ } from "./references";
190
+
175
191
  // Field components registry (mirror of the Go schema's component set)
176
192
  export {
177
193
  componentsByType,
@@ -0,0 +1,107 @@
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
+ // ReferenceDocument is what opening a document needs: enough to name it, the
42
+ // collection it belongs to (an open column's context), and its path (its
43
+ // address). A ReferenceTarget and a Referrer both satisfy it.
44
+ export interface ReferenceDocument {
45
+ id: string;
46
+ collection: string;
47
+ path: string;
48
+ label: string;
49
+ }
50
+
51
+ // ReferenceResolution is the answer to "what does this stored key name?".
52
+ // `target` null with `ambiguous` false is a dangling reference; `ambiguous`
53
+ // means a field key several documents share, and `candidates` holds them.
54
+ export interface ReferenceResolution {
55
+ key: string;
56
+ target?: ReferenceTarget | null;
57
+ ambiguous: boolean;
58
+ candidates: ReferenceTarget[];
59
+ }
60
+
61
+ // Referrer is one document that points at another, and the field it does so
62
+ // from.
63
+ export interface Referrer {
64
+ id: string;
65
+ collection: string;
66
+ path: string;
67
+ label: string;
68
+ fieldPath: string;
69
+ }
70
+
71
+ // ReferenceApi is the data seam between the design system and its host, the
72
+ // same split as MediaApi: the DS owns the picker and the display, the host owns
73
+ // fetching. Omitting it from ContentBrowser renders reference fields read-only.
74
+ export interface ReferenceApi {
75
+ // Documents the field may point at — the picker's list. `collections` and
76
+ // `key` are the field's ReferenceDef; `query` matches labels.
77
+ search: (params: { collections: string[]; key: string; query?: string; limit?: number }) => Promise<ReferenceTarget[]>;
78
+ // Resolve stored keys to their documents, batched per document.
79
+ resolve: (collections: string[], key: string, keys: string[]) => Promise<ReferenceResolution[]>;
80
+ // Documents that point at one — "Referenced by". Omit to hide the section.
81
+ referrers?: (documentId: string) => Promise<Referrer[]>;
82
+ }
83
+
84
+ // The key child an object reference stores by default. Mirrors the Go
85
+ // DefaultKeyName.
86
+ export const DEFAULT_REFERENCE_KEY_NAME = "ref";
87
+
88
+ // referenceKey reads the stored key out of a reference field's value, for
89
+ // either shape. Empty when the field is unset.
90
+ export function referenceKey(value: unknown, shape: "string" | "object", def: ReferenceDef): string {
91
+ if (shape === "object") {
92
+ if (typeof value !== "object" || value === null) return "";
93
+ const key = (value as Record<string, unknown>)[def.keyName || DEFAULT_REFERENCE_KEY_NAME];
94
+ return typeof key === "string" ? key.trim() : "";
95
+ }
96
+ return typeof value === "string" ? value.trim() : "";
97
+ }
98
+
99
+ // referenceValue builds the value the field writes when a target is picked:
100
+ // the key alone in a string field; in an object field the key under keyName,
101
+ // with any copies the value already carried kept until the server replaces
102
+ // them on save — writing nothing else is what keeps the copies the server's.
103
+ export function referenceValue(target: ReferenceTarget, shape: "string" | "object", def: ReferenceDef, previous: unknown): unknown {
104
+ if (shape === "string") return target.key;
105
+ const prev = typeof previous === "object" && previous !== null ? (previous as Record<string, unknown>) : {};
106
+ return { ...prev, [def.keyName || DEFAULT_REFERENCE_KEY_NAME]: target.key };
107
+ }