@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 +1 -1
- package/src/__tests__/ContentBrowser.references.test.tsx +260 -0
- package/src/components/ContentBrowser.tsx +128 -13
- package/src/components/DocumentDetails.tsx +0 -0
- package/src/components/ReferenceField.tsx +0 -0
- package/src/fieldComponents.ts +2 -2
- package/src/index.ts +16 -0
- package/src/references.ts +107 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gogitcms/design-system",
|
|
3
|
-
"version": "0.16.0-next.
|
|
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
|
-
|
|
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
|
|
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
|
|
2507
|
-
// Delete, each shown only when its handler is provided.
|
|
2508
|
-
//
|
|
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
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
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
|
-
|
|
5520
|
+
<ReferenceProvider api={props.references} onOpen={props.onOpenReference}>
|
|
5521
|
+
{isDesktop ? <DesktopBrowser {...withSections} /> : <MobileBrowser {...withSections} />}
|
|
5522
|
+
</ReferenceProvider>
|
|
5408
5523
|
</MediaProvider>
|
|
5409
5524
|
);
|
|
5410
5525
|
}
|
|
Binary file
|
|
Binary file
|
package/src/fieldComponents.ts
CHANGED
|
@@ -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
|
+
}
|