@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 +1 -1
- package/src/__tests__/ContentBrowser.references.test.tsx +179 -0
- package/src/components/ContentBrowser.tsx +89 -11
- package/src/components/DocumentDetails.tsx +0 -0
- package/src/components/ReferenceField.tsx +0 -0
- package/src/fieldComponents.ts +2 -2
- package/src/index.ts +15 -0
- package/src/references.ts +97 -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.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
|
|
2507
|
-
// Delete, each shown only when its handler is provided.
|
|
2508
|
-
//
|
|
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
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
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
|
-
|
|
5483
|
+
<ReferenceProvider api={props.references}>
|
|
5484
|
+
{isDesktop ? <DesktopBrowser {...withSections} /> : <MobileBrowser {...withSections} />}
|
|
5485
|
+
</ReferenceProvider>
|
|
5408
5486
|
</MediaProvider>
|
|
5409
5487
|
);
|
|
5410
5488
|
}
|
|
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,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
|
+
}
|