@gogitcms/design-system 0.16.0-next.3 → 0.16.0-next.5

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.3",
3
+ "version": "0.16.0-next.5",
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,385 @@
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 } from "../components/ContentBrowser";
5
+ import type { DocumentVersion, HistoryApi } from "../history";
6
+ import { refreshDrafts } from "../components/documentDrafts";
7
+ import type { DocumentChange } from "../components/ChangeDetail";
8
+
9
+ // Force the desktop layout (jsdom reports width 0 → mobile otherwise).
10
+ jest.mock("../ThemeProvider", () => {
11
+ const actual = jest.requireActual("../ThemeProvider");
12
+ return { ...actual, useResponsive: () => ({ width: 1300, height: 900, isDesktop: true, isMobile: false }) };
13
+ });
14
+
15
+ const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
16
+
17
+ // A restore commits, and committing records a draft. Without clearing them each
18
+ // test seeds its form from the PREVIOUS test's restore — which reads as a field
19
+ // that was already rolled back and so has nothing left to write.
20
+ beforeEach(() => {
21
+ localStorage.clear();
22
+ refreshDrafts();
23
+ });
24
+
25
+ const sections: CmsNavSection[] = [
26
+ { title: "Content", items: [{ key: "posts", label: "Posts", icon: "newspaper" }] },
27
+ ];
28
+
29
+ const entry: CmsEntry = {
30
+ id: "doc-1",
31
+ path: "posts/hello.md",
32
+ title: "Hello",
33
+ body: "",
34
+ fields: [
35
+ { name: "title", type: "string", value: "Hello" },
36
+ { name: "status", type: "string", value: "published" },
37
+ { name: "body", type: "string", component: "body", source: "body", value: "Current body." },
38
+ ],
39
+ };
40
+
41
+ const published: DocumentVersion = {
42
+ sha: "9f2c1abcdef",
43
+ shortSha: "9f2c1ab",
44
+ message: "Publish the post\n\nA body the list has no room for.",
45
+ authorName: "Ada",
46
+ authoredAt: "2026-03-01T12:00:00Z",
47
+ url: "https://github.com/acme/site/commit/9f2c1ab",
48
+ filesChanged: 3,
49
+ };
50
+
51
+ const drafted: DocumentVersion = {
52
+ sha: "3b7de1098877",
53
+ shortSha: "3b7de10",
54
+ message: "Draft the post",
55
+ authorName: "Ada",
56
+ authoredAt: "2026-02-01T12:00:00Z",
57
+ url: "https://github.com/acme/site/commit/3b7de10",
58
+ filesChanged: 1,
59
+ };
60
+
61
+ const draftedDetail: DocumentChange = {
62
+ path: "posts/hello.md",
63
+ status: "M",
64
+ label: "Hello",
65
+ added: 1,
66
+ removed: 1,
67
+ fields: [
68
+ { name: "title", kind: "unchanged", before: "Hello", after: "Hello" },
69
+ { name: "status", kind: "changed", before: "draft", after: "published" },
70
+ ],
71
+ bodyBefore: "The earlier body.",
72
+ bodyAfter: "Current body.",
73
+ };
74
+
75
+ function makeApi(over: Partial<HistoryApi> = {}): HistoryApi {
76
+ return {
77
+ list: jest.fn(async () => [published, drafted]),
78
+ get: jest.fn(async () => draftedDetail),
79
+ ...over,
80
+ };
81
+ }
82
+
83
+ function renderBrowser(history?: HistoryApi, over: { readOnly?: boolean } = {}) {
84
+ const onSaveEntry = jest.fn(async () => {});
85
+ render(
86
+ wrap(
87
+ <ContentBrowser
88
+ onSaveEntry={onSaveEntry}
89
+ readOnly={over.readOnly}
90
+ workspace={{ name: "acme/site", initials: "AC", branch: "main", changed: 0 }}
91
+ sections={sections}
92
+ activeNavKey="posts"
93
+ onSelectNav={jest.fn()}
94
+ entries={[entry]}
95
+ selectedEntryId={entry.id}
96
+ onSelectEntry={jest.fn()}
97
+ userInitials="ED"
98
+ history={history}
99
+ />,
100
+ ),
101
+ );
102
+ return onSaveEntry;
103
+ }
104
+
105
+ /**
106
+ * Opens history and waits for the newest version's diff to actually land — the
107
+ * pane mounts before its first fetch resolves, and everything to do with
108
+ * restoring is rendered from the fetched diff.
109
+ */
110
+ async function openHistory(history?: HistoryApi, over: { readOnly?: boolean } = {}) {
111
+ const onSaveEntry = renderBrowser(history ?? makeApi(), over);
112
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
113
+ await screen.findByTestId("document-history");
114
+ await screen.findByTestId("change-detail");
115
+ return onSaveEntry;
116
+ }
117
+
118
+ // The affordance only exists where history does. A deployment with no provider
119
+ // should show no button at all rather than one that errors when pressed.
120
+ test("no history seam means no history button", () => {
121
+ renderBrowser(undefined);
122
+ expect(screen.queryByTestId("document-history-toggle")).not.toBeInTheDocument();
123
+ });
124
+
125
+ test("the history button gives the document's column over to the version browser", async () => {
126
+ renderBrowser(makeApi());
127
+
128
+ // The editor form is what a column shows until history is asked for.
129
+ expect(screen.getByTestId("column-scroll")).toBeInTheDocument();
130
+ expect(screen.queryByTestId("document-history")).not.toBeInTheDocument();
131
+
132
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
133
+
134
+ expect(await screen.findByTestId("document-history")).toBeInTheDocument();
135
+ // The form is gone — history takes the column's body, not a slice of it.
136
+ expect(screen.queryByTestId("column-scroll")).not.toBeInTheDocument();
137
+ });
138
+
139
+ test("the version list shows each commit's subject, author, sha and file count", async () => {
140
+ renderBrowser(makeApi());
141
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
142
+
143
+ expect(await screen.findByTestId("version-9f2c1ab")).toBeInTheDocument();
144
+ expect(screen.getByTestId("version-3b7de10")).toBeInTheDocument();
145
+
146
+ // The subject only — the message body would push every other row off screen.
147
+ expect(screen.getByText("Publish the post")).toBeInTheDocument();
148
+ expect(screen.queryByText(/A body the list has no room for/)).not.toBeInTheDocument();
149
+
150
+ expect(screen.getByText("3 files")).toBeInTheDocument();
151
+ // Singular, because "1 files" is the kind of thing people notice.
152
+ expect(screen.getByText("1 file")).toBeInTheDocument();
153
+ });
154
+
155
+ // An empty diff beside a full list is a second click for nothing.
156
+ test("opening history selects the newest version and loads its diff", async () => {
157
+ const api = makeApi();
158
+ renderBrowser(api);
159
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
160
+
161
+ await waitFor(() => expect(api.get).toHaveBeenCalledWith({ documentId: "doc-1", sha: published.sha }));
162
+ expect(await screen.findByTestId("change-detail")).toBeInTheDocument();
163
+ });
164
+
165
+ test("selecting a version loads that version's document and diff", async () => {
166
+ const api = makeApi();
167
+ renderBrowser(api);
168
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
169
+
170
+ fireEvent.click(await screen.findByTestId("version-3b7de10"));
171
+
172
+ await waitFor(() => expect(api.get).toHaveBeenCalledWith({ documentId: "doc-1", sha: drafted.sha }));
173
+ // The version's value on the left of the diff, the current one on the right.
174
+ expect(await screen.findByText("draft")).toBeInTheDocument();
175
+ expect(screen.getByText("published")).toBeInTheDocument();
176
+ });
177
+
178
+ // A document with no commits is a real and common state — one created in the
179
+ // CMS and not yet exported. It must not read as a failure.
180
+ test("a document with no commits explains itself", async () => {
181
+ renderBrowser(makeApi({ list: jest.fn(async () => []) }));
182
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
183
+
184
+ expect(await screen.findByTestId("history-empty")).toBeInTheDocument();
185
+ expect(screen.queryByTestId("history-error")).not.toBeInTheDocument();
186
+ });
187
+
188
+ test("a failed history says so rather than showing an empty list", async () => {
189
+ renderBrowser(makeApi({ list: jest.fn(async () => { throw new Error("GitHub is unreachable"); }) }));
190
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
191
+
192
+ expect(await screen.findByText("GitHub is unreachable")).toBeInTheDocument();
193
+ expect(screen.queryByTestId("history-empty")).not.toBeInTheDocument();
194
+ });
195
+
196
+ test("the button toggles back, returning the column to the editor", async () => {
197
+ renderBrowser(makeApi());
198
+ const toggle = screen.getByTestId("document-history-toggle");
199
+
200
+ fireEvent.click(toggle);
201
+ expect(await screen.findByTestId("document-history")).toBeInTheDocument();
202
+
203
+ fireEvent.click(toggle);
204
+ expect(screen.queryByTestId("document-history")).not.toBeInTheDocument();
205
+ expect(screen.getByTestId("column-scroll")).toBeInTheDocument();
206
+ });
207
+
208
+ test("the pane's own close control leaves history too", async () => {
209
+ renderBrowser(makeApi());
210
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
211
+
212
+ fireEvent.click(await screen.findByTestId("history-close"));
213
+ expect(screen.queryByTestId("document-history")).not.toBeInTheDocument();
214
+ });
215
+
216
+ // ---- restoring fields from a past version ---------------------------------
217
+
218
+ // A tick means "take this version's value". An unchanged field's two sides are
219
+ // the same value, so a box on it could only ever be a no-op dressed up as a
220
+ // choice.
221
+ test("only changed fields offer a box", async () => {
222
+ await openHistory();
223
+
224
+ expect(screen.getByTestId("restore-field-status")).toBeInTheDocument();
225
+ expect(screen.getByTestId("restore-field-body")).toBeInTheDocument();
226
+ expect(screen.queryByTestId("restore-field-title")).not.toBeInTheDocument();
227
+ });
228
+
229
+ // The bar is mounted from the start rather than appearing on the first tick:
230
+ // otherwise it shifts the diff under the hand that just ticked something, and
231
+ // nothing tells you what the boxes are for until you have used one.
232
+ test("the restore bar is present but inert until something is picked", async () => {
233
+ await openHistory();
234
+
235
+ expect(screen.getByTestId("restore-bar")).toBeInTheDocument();
236
+ expect(screen.getByTestId("restore-apply")).toBeDisabled();
237
+ expect(screen.getByText("Tick a field to take its earlier value")).toBeInTheDocument();
238
+
239
+ fireEvent.click(screen.getByTestId("restore-field-status"));
240
+ expect(screen.getByTestId("restore-apply")).not.toBeDisabled();
241
+ expect(screen.getByText("1 field selected")).toBeInTheDocument();
242
+ });
243
+
244
+ test("restoring a field puts its old value in the editor and closes history", async () => {
245
+ await openHistory();
246
+
247
+ fireEvent.click(screen.getByTestId("restore-field-status"));
248
+ fireEvent.click(screen.getByTestId("restore-apply"));
249
+
250
+ // Back in the form, which is where the restored value now is.
251
+ expect(screen.queryByTestId("document-history")).not.toBeInTheDocument();
252
+ expect(screen.getByTestId("column-scroll")).toBeInTheDocument();
253
+
254
+ // The picked field took the version's value; the others are untouched.
255
+ expect(screen.getByDisplayValue("draft")).toBeInTheDocument();
256
+ expect(screen.getByDisplayValue("Hello")).toBeInTheDocument();
257
+ });
258
+
259
+ // The restore is an edit, not a write: it lands in the form and waits to be
260
+ // saved like anything the author typed.
261
+ test("a restore leaves the document unsaved rather than saving it", async () => {
262
+ const onSaveEntry = await openHistory();
263
+
264
+ fireEvent.click(screen.getByTestId("restore-field-status"));
265
+ fireEvent.click(screen.getByTestId("restore-apply"));
266
+
267
+ expect(onSaveEntry).not.toHaveBeenCalled();
268
+ await waitFor(() => expect(screen.getByTestId("save-entry")).not.toBeDisabled());
269
+ // And it can be thrown away like any other edit.
270
+ expect(screen.getByTestId("discard-changes")).toBeInTheDocument();
271
+
272
+ fireEvent.click(screen.getByTestId("save-entry"));
273
+ await waitFor(() =>
274
+ expect(onSaveEntry).toHaveBeenCalledWith(
275
+ expect.objectContaining({ fields: expect.objectContaining({ status: "draft" }) }),
276
+ ),
277
+ );
278
+ });
279
+
280
+ // The body is a field in the form like any other; which one it is comes from
281
+ // the schema's `source: "body"`.
282
+ test("the body restores onto the model's body field", async () => {
283
+ const onSaveEntry = await openHistory();
284
+
285
+ fireEvent.click(screen.getByTestId("restore-field-body"));
286
+ fireEvent.click(screen.getByTestId("restore-apply"));
287
+
288
+ fireEvent.click(screen.getByTestId("save-entry"));
289
+ await waitFor(() =>
290
+ expect(onSaveEntry).toHaveBeenCalledWith(expect.objectContaining({ body: "The earlier body." })),
291
+ );
292
+ });
293
+
294
+ test("several fields restore together", async () => {
295
+ const onSaveEntry = await openHistory();
296
+
297
+ fireEvent.click(screen.getByTestId("restore-field-status"));
298
+ fireEvent.click(screen.getByTestId("restore-field-body"));
299
+ expect(screen.getByText("2 fields selected")).toBeInTheDocument();
300
+ expect(screen.getByTestId("restore-apply")).toHaveTextContent("Restore 2 fields");
301
+
302
+ fireEvent.click(screen.getByTestId("restore-apply"));
303
+ fireEvent.click(screen.getByTestId("save-entry"));
304
+ await waitFor(() =>
305
+ expect(onSaveEntry).toHaveBeenCalledWith(
306
+ expect.objectContaining({
307
+ fields: expect.objectContaining({ status: "draft" }),
308
+ body: "The earlier body.",
309
+ }),
310
+ ),
311
+ );
312
+ });
313
+
314
+ test("unticking a field, and Clear, take it back out of the selection", async () => {
315
+ await openHistory();
316
+
317
+ fireEvent.click(screen.getByTestId("restore-field-status"));
318
+ fireEvent.click(screen.getByTestId("restore-field-status"));
319
+ expect(screen.getByTestId("restore-apply")).toBeDisabled();
320
+
321
+ fireEvent.click(screen.getByTestId("restore-field-status"));
322
+ fireEvent.click(screen.getByTestId("restore-clear"));
323
+ expect(screen.getByTestId("restore-apply")).toBeDisabled();
324
+ });
325
+
326
+ // A tick names a VALUE, not a field. Carrying it to the next version would keep
327
+ // the box ticked while silently repointing it at a different value.
328
+ test("switching version clears what was picked", async () => {
329
+ await openHistory();
330
+
331
+ fireEvent.click(screen.getByTestId("restore-field-status"));
332
+ expect(screen.getByTestId("restore-apply")).not.toBeDisabled();
333
+
334
+ fireEvent.click(screen.getByTestId("version-3b7de10"));
335
+ await waitFor(() => expect(screen.getByTestId("restore-apply")).toBeDisabled());
336
+ });
337
+
338
+ // A read-only document can be read back through but not rewritten.
339
+ test("a read-only document gets history with no restore at all", async () => {
340
+ await openHistory(makeApi(), { readOnly: true });
341
+
342
+ expect(screen.queryByTestId("restore-bar")).not.toBeInTheDocument();
343
+ expect(screen.queryByTestId("restore-field-status")).not.toBeInTheDocument();
344
+ });
345
+
346
+ // A version identical to the current document has no field whose earlier value
347
+ // is anything but the value already in the form.
348
+ test("an unchanged version offers nothing to restore", async () => {
349
+ const unchanged: DocumentChange = {
350
+ path: "posts/hello.md",
351
+ status: "U",
352
+ label: "Hello",
353
+ added: 0,
354
+ removed: 0,
355
+ fields: [{ name: "status", kind: "unchanged", before: "published", after: "published" }],
356
+ };
357
+ await openHistory(makeApi({ get: jest.fn(async () => unchanged) }));
358
+
359
+ expect(screen.queryByTestId("restore-bar")).not.toBeInTheDocument();
360
+ });
361
+
362
+ // Restoring needs somewhere for the values to go. A host that wired no save
363
+ // would otherwise offer a control that dirties a form nothing can persist.
364
+ test("a document with no save handler gets history with no restore", async () => {
365
+ render(
366
+ wrap(
367
+ <ContentBrowser
368
+ workspace={{ name: "acme/site", initials: "AC", branch: "main", changed: 0 }}
369
+ sections={sections}
370
+ activeNavKey="posts"
371
+ onSelectNav={jest.fn()}
372
+ entries={[entry]}
373
+ selectedEntryId={entry.id}
374
+ onSelectEntry={jest.fn()}
375
+ userInitials="ED"
376
+ history={makeApi()}
377
+ />,
378
+ ),
379
+ );
380
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
381
+ await screen.findByTestId("change-detail");
382
+
383
+ expect(screen.queryByTestId("restore-bar")).not.toBeInTheDocument();
384
+ expect(screen.queryByTestId("restore-field-status")).not.toBeInTheDocument();
385
+ });