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

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.13",
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.",
@@ -68,6 +68,7 @@ const galleryField: EntryField = {
68
68
  type: "array",
69
69
  of: "string",
70
70
  component: "list",
71
+ itemComponent: "media",
71
72
  media: "uploads",
72
73
  storeAs: "public",
73
74
  value: ["/uploads/hero.png"],
@@ -89,7 +90,7 @@ test("array items render media pickers when the array names a media set", async
89
90
  });
90
91
 
91
92
  test("a list with no media set stays a plain list of inputs", () => {
92
- renderWith([{ ...galleryField, media: undefined, storeAs: undefined, value: ["x"] }]);
93
+ renderWith([{ ...galleryField, itemComponent: undefined, media: undefined, storeAs: undefined, value: ["x"] }]);
93
94
  expect(screen.queryByTestId("media-choose")).not.toBeInTheDocument();
94
95
  });
95
96
 
@@ -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
  );
@@ -123,12 +129,87 @@ test("an object reference shows its embedded copies read-only and picks by key c
123
129
 
124
130
  test("an array of references renders a picker per item", async () => {
125
131
  renderWith([
126
- { name: "related", label: "Related", type: "array", of: "string", component: "list", reference: authorDef, value: [ada.key, bob.key] },
132
+ { name: "related", label: "Related", type: "array", of: "string", component: "list", itemComponent: "reference", reference: authorDef, value: [ada.key, bob.key] },
127
133
  ]);
128
134
  expect(await screen.findAllByTestId("reference-choose")).toHaveLength(2);
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", itemComponent: "reference", 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", itemComponent: "reference", 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";
@@ -73,21 +74,26 @@ export type EntryField = {
73
74
  source?: string;
74
75
  format?: string;
75
76
  flavor?: string;
76
- // For a media picker (`component: "media"`): which configured media set to
77
- // browse, and — on a string field — which form of the path to write. `media`
78
- // comes from display.media, `storeAs` from display.store_as. On an array
79
- // field these describe its *items*, making each one a media picker.
77
+ // For a media picker (`component: "media"`, or an array whose items are —
78
+ // `itemComponent: "media"`): which configured media set to browse, and —
79
+ // for a string value — which form of the path to write. Both come from the
80
+ // field's `media:` block; on an array they describe its *items*.
80
81
  media?: string;
81
82
  storeAs?: string;
82
83
  // 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).
84
+ // are — `itemComponent: "reference"`): what the field points at and stores.
85
+ // An object-shaped reference declares its `fields` — the key child and the
86
+ // embedded copies — and the copies are the server's: recomputed on every
87
+ // save and shown read-only here (docs/references.md).
87
88
  reference?: ReferenceDef;
88
89
  value: unknown;
89
90
  // Item type for an array field (string | number | boolean | object).
90
91
  of?: string;
92
+ // The control each item of an array renders, from display.item.component.
93
+ // `list` describes the array; what an item is — a text input, a media
94
+ // picker, a document picker — is declared here, never inferred from the
95
+ // array's media or reference block. Absent means the default for `of`.
96
+ itemComponent?: string;
91
97
  // Child fields for an object field (or an array whose `of` is object).
92
98
  fields?: EntryField[];
93
99
  // Object shapes a mixed-list array (component: "mixedList") can hold.
@@ -373,6 +379,12 @@ export type ContentBrowserProps = {
373
379
  // the document picker and the "Referenced by" list, the host owns fetching.
374
380
  // Omitted → reference fields render read-only.
375
381
  references?: ReferenceApi;
382
+ // Open a referenced document — the Open action on a reference field's target
383
+ // and on the rows of the Details modal. The document may belong to any
384
+ // collection, so the host, which owns the open columns and the URL, does the
385
+ // opening (a new column on desktop, in place on mobile). Omitted, the action
386
+ // is not offered.
387
+ onOpenReference?: (doc: ReferenceDocument) => void;
376
388
 
377
389
  // Forms data seam, the same split (docs/forms.md §10.2). Omitted → the Forms
378
390
  // surface never renders, which is what a config declaring no forms looks like.
@@ -1704,22 +1716,44 @@ function ListControl({
1704
1716
  [next[i], next[j]] = [next[j], next[i]];
1705
1717
  onChange(next);
1706
1718
  };
1707
- const addItem = () => onChange([...items, isObject ? {} : ""]);
1719
+ // A reference list's "Add item" opens the picker straight away and appends
1720
+ // what was picked: the only thing such an item can hold is a picked
1721
+ // document, so an empty slot to fill in afterwards is a step nobody wants.
1722
+ // Without a host seam there is nothing to pick from, and the button is
1723
+ // disabled as the field's own Choose button is. "Reference list" is the
1724
+ // item's declared control, not the presence of a block: an array whose
1725
+ // items are not declared pickers is a plain list whatever else it carries.
1726
+ const reference = field.itemComponent === "reference" ? field.reference : undefined;
1727
+ const referenceApi = useReferenceApi();
1728
+ const [pickingNew, setPickingNew] = React.useState(false);
1729
+ const addItem = () => {
1730
+ if (reference) {
1731
+ if (referenceApi) setPickingNew(true);
1732
+ return;
1733
+ }
1734
+ onChange([...items, isObject ? {} : ""]);
1735
+ };
1736
+ const addPicked = (target: ReferenceTarget) => {
1737
+ setPickingNew(false);
1738
+ if (!reference) return;
1739
+ onChange([...items, referenceValue(target, isObject ? "object" : "string", reference, undefined)]);
1740
+ };
1708
1741
 
1709
- // Items inherit the array's *item-level* display config, not the array's own
1710
- // component — `list` describes the array, and is not a control an item could
1711
- // render. An array that names a media set makes each item a media picker;
1712
- // everything else falls back to the default control for the item type.
1742
+ // Items render the array's *declared* item control (display.item.component),
1743
+ // not the array's own component — `list` describes the array, and is not a
1744
+ // control an item could render. The media and reference blocks describe the
1745
+ // items too, so they travel with the item; they never pick its control. An
1746
+ // array that declares nothing falls back to the default control for `of`.
1713
1747
  const itemField: EntryField = {
1714
1748
  name: field.name,
1715
1749
  type: itemType,
1716
1750
  of: undefined,
1717
1751
  fields: field.fields,
1718
1752
  value: null,
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 } : {}),
1753
+ component: field.itemComponent,
1754
+ media: field.media,
1755
+ storeAs: field.storeAs,
1756
+ reference: field.reference,
1723
1757
  };
1724
1758
 
1725
1759
  // The list's own path, from the FieldControl wrapping this control; an item
@@ -1734,9 +1768,20 @@ function ListControl({
1734
1768
  ))}
1735
1769
  {!readOnly && canAdd ? (
1736
1770
  <View style={{ alignSelf: "flex-start" }}>
1737
- <Button title="Add item" variant="default" size="sm" iconLeft="plus" onPress={addItem} testID="list-add" />
1771
+ <Button
1772
+ title="Add item"
1773
+ variant="default"
1774
+ size="sm"
1775
+ iconLeft="plus"
1776
+ onPress={addItem}
1777
+ disabled={!!reference && !referenceApi}
1778
+ testID="list-add"
1779
+ />
1738
1780
  </View>
1739
1781
  ) : null}
1782
+ {reference ? (
1783
+ <ReferencePicker visible={pickingNew} onClose={() => setPickingNew(false)} onSelect={addPicked} def={reference} />
1784
+ ) : null}
1740
1785
  </View>
1741
1786
  );
1742
1787
  }
@@ -5480,7 +5525,7 @@ export function ContentBrowser(props: ContentBrowserProps) {
5480
5525
  // object store) renders media fields read-only.
5481
5526
  return (
5482
5527
  <MediaProvider api={props.media}>
5483
- <ReferenceProvider api={props.references}>
5528
+ <ReferenceProvider api={props.references} onOpen={props.onOpenReference}>
5484
5529
  {isDesktop ? <DesktopBrowser {...withSections} /> : <MobileBrowser {...withSections} />}
5485
5530
  </ReferenceProvider>
5486
5531
  </MediaProvider>
Binary file
@@ -61,7 +61,7 @@ export type MediaFieldProps = {
61
61
  // object field and an empty string field look identical at runtime — and
62
62
  // guessing would write the wrong shape into the document on first pick.
63
63
  shape: "string" | "object";
64
- // The media set this field browses, from the config's display.media.
64
+ // The media set this field browses, from the config's media block.
65
65
  set?: string;
66
66
  // Which form a string field writes. Ignored for the object form, which stores
67
67
  // the asset id.
@@ -388,7 +388,7 @@ function AmbiguousNotice({
388
388
  // for an image without knowing anything about the picker UI.
389
389
  //
390
390
  // `set` defaults to the first configured set when the field names none (a body
391
- // field has no display.media), and the picker's own switcher covers the rest.
391
+ // field has no media block), and the picker's own switcher covers the rest.
392
392
  export function useFieldMedia(opts: { set?: string; storeAs?: string }): {
393
393
  media: FieldMediaApi | undefined;
394
394
  picker: React.ReactNode;
@@ -127,10 +127,16 @@ function NotificationRow({
127
127
  item,
128
128
  divided,
129
129
  onPress,
130
+ // How much of the body a row shows. Two lines suits the bell's menu, which is
131
+ // a glance at what happened; the full-screen list gives it more, because the
132
+ // body of an error notification is the error itself and a reader who opened
133
+ // the list to find out what went wrong has nowhere else to read it.
134
+ bodyLines = 2,
130
135
  }: {
131
136
  item: NotificationItem;
132
137
  divided?: boolean;
133
138
  onPress?: () => void;
139
+ bodyLines?: number;
134
140
  }) {
135
141
  const t = useTheme();
136
142
  const titleColor = item.level === "error" ? t.color.diffDelFg : t.color.textPrimary;
@@ -199,7 +205,9 @@ function NotificationRow({
199
205
  </View>
200
206
  <Text variant="monoSm" color="tertiary">{relativeTime(item.createdAt)}</Text>
201
207
  </View>
202
- {item.body ? <InlineCode text={item.body} variant="monoSm" color={t.color.textSecondary} numberOfLines={2} /> : null}
208
+ {item.body ? (
209
+ <InlineCode text={item.body} variant="monoSm" color={t.color.textSecondary} numberOfLines={bodyLines} />
210
+ ) : null}
203
211
  {meta ? (
204
212
  <Text variant="monoSm" color="tertiary" numberOfLines={1}>{meta}</Text>
205
213
  ) : null}
@@ -412,6 +420,7 @@ export function NotificationList({
412
420
  item={n}
413
421
  divided={i < notifications.length - 1}
414
422
  onPress={onPressItem ? () => onPressItem(n) : undefined}
423
+ bodyLines={8}
415
424
  />
416
425
  ))}
417
426
  </ScrollView>
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
@@ -17,7 +17,7 @@ export interface ReferenceDef {
17
17
  key: string;
18
18
  // For the object shape, the child that holds the key ("ref" unless renamed).
19
19
  keyName: string;
20
- // The embedded copies, in the order the derived children appear.
20
+ // The embedded copies, in the order their children are listed.
21
21
  embed: ReferenceEmbed[];
22
22
  // What deleting a referenced document does: restrict | unset.
23
23
  onDelete: string;
@@ -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.