@gogitcms/design-system 0.16.0-next.11 → 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.11",
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.",
@@ -44,8 +44,13 @@ const embedDef: ReferenceDef = {
44
44
 
45
45
  // `null` renders without a references seam; the default is a working one.
46
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()) {
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
+ ) {
49
54
  const onSave = jest.fn();
50
55
  const entry: CmsEntry = { id: "a", path: "content/articles/a.md", title: "Alpha", body: "", fields };
51
56
  render(
@@ -59,6 +64,7 @@ function renderWith(fields: EntryField[], api: ReferenceApi | null = makeApi())
59
64
  userInitials="ED"
60
65
  references={api ?? undefined}
61
66
  onSaveEntry={onSave}
67
+ {...extra}
62
68
  />
63
69
  </ThemeProvider>,
64
70
  );
@@ -129,6 +135,81 @@ test("an array of references renders a picker per item", async () => {
129
135
  expect(await screen.findByText("Bob")).toBeInTheDocument();
130
136
  });
131
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
+
132
213
  test("the Details modal shows the document's metadata and its references both ways", async () => {
133
214
  renderWith([
134
215
  { name: "title", label: "Title", type: "string", value: "Alpha" },
@@ -32,9 +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, ReferenceProvider } from "./ReferenceField";
35
+ import { ReferenceField, ReferencePicker, ReferenceProvider, useReferenceApi } from "./ReferenceField";
36
36
  import { DocumentDetails } from "./DocumentDetails";
37
- import type { ReferenceApi, ReferenceDef } from "../references";
37
+ import type { ReferenceApi, ReferenceDef, ReferenceDocument, ReferenceTarget } from "../references";
38
+ import { referenceValue } from "../references";
38
39
  import { MediaBrowser } from "./MediaBrowser";
39
40
  import { FormsBrowser } from "./FormsBrowser";
40
41
  import { FORMS_NAV_KEY, formsNavKey, isFormsNavKey, parseFormsNavKey, type FormsApi } from "../forms";
@@ -373,6 +374,12 @@ export type ContentBrowserProps = {
373
374
  // the document picker and the "Referenced by" list, the host owns fetching.
374
375
  // Omitted → reference fields render read-only.
375
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;
376
383
 
377
384
  // Forms data seam, the same split (docs/forms.md §10.2). Omitted → the Forms
378
385
  // surface never renders, which is what a config declaring no forms looks like.
@@ -1704,7 +1711,26 @@ function ListControl({
1704
1711
  [next[i], next[j]] = [next[j], next[i]];
1705
1712
  onChange(next);
1706
1713
  };
1707
- 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
+ };
1708
1734
 
1709
1735
  // Items inherit the array's *item-level* display config, not the array's own
1710
1736
  // component — `list` describes the array, and is not a control an item could
@@ -1734,9 +1760,20 @@ function ListControl({
1734
1760
  ))}
1735
1761
  {!readOnly && canAdd ? (
1736
1762
  <View style={{ alignSelf: "flex-start" }}>
1737
- <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
+ />
1738
1772
  </View>
1739
1773
  ) : null}
1774
+ {reference ? (
1775
+ <ReferencePicker visible={pickingNew} onClose={() => setPickingNew(false)} onSelect={addPicked} def={reference} />
1776
+ ) : null}
1740
1777
  </View>
1741
1778
  );
1742
1779
  }
@@ -5480,7 +5517,7 @@ export function ContentBrowser(props: ContentBrowserProps) {
5480
5517
  // object store) renders media fields read-only.
5481
5518
  return (
5482
5519
  <MediaProvider api={props.media}>
5483
- <ReferenceProvider api={props.references}>
5520
+ <ReferenceProvider api={props.references} onOpen={props.onOpenReference}>
5484
5521
  {isDesktop ? <DesktopBrowser {...withSections} /> : <MobileBrowser {...withSections} />}
5485
5522
  </ReferenceProvider>
5486
5523
  </MediaProvider>
Binary file
Binary file
package/src/index.ts CHANGED
@@ -173,14 +173,15 @@ export type {
173
173
  } from "./media";
174
174
 
175
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";
176
+ export { ReferenceField, ReferencePicker, ReferenceProvider, useReferenceApi, useOpenReference } from "./components/ReferenceField";
177
+ export type { ReferenceFieldProps, ReferencePickerProps, OpenReference } from "./components/ReferenceField";
178
178
  export { DocumentDetails, collectSites } from "./components/DocumentDetails";
179
179
  export type { DocumentDetailsProps, DetailsField } from "./components/DocumentDetails";
180
180
  export { DEFAULT_REFERENCE_KEY_NAME, referenceKey, referenceValue } from "./references";
181
181
  export type {
182
182
  ReferenceApi,
183
183
  ReferenceDef,
184
+ ReferenceDocument,
184
185
  ReferenceEmbed,
185
186
  ReferenceTarget,
186
187
  ReferenceResolution,
package/src/references.ts CHANGED
@@ -38,6 +38,16 @@ export interface ReferenceTarget {
38
38
  label: string;
39
39
  }
40
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
+
41
51
  // ReferenceResolution is the answer to "what does this stored key name?".
42
52
  // `target` null with `ambiguous` false is a dangling reference; `ambiguous`
43
53
  // means a field key several documents share, and `candidates` holds them.