@gogitcms/design-system 0.15.0-next.3

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.
Files changed (70) hide show
  1. package/README.md +77 -0
  2. package/css/components.css +312 -0
  3. package/css/tokens.css +212 -0
  4. package/package.json +65 -0
  5. package/src/ThemeProvider.tsx +86 -0
  6. package/src/__tests__/ApplyChangesModal.test.tsx +148 -0
  7. package/src/__tests__/BranchImport.test.tsx +46 -0
  8. package/src/__tests__/Button.test.tsx +45 -0
  9. package/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
  10. package/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
  11. package/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
  12. package/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
  13. package/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
  14. package/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
  15. package/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
  16. package/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
  17. package/src/__tests__/ContentBrowser.media.test.tsx +140 -0
  18. package/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
  19. package/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
  20. package/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
  21. package/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
  22. package/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
  23. package/src/__tests__/ContentBrowser.search.test.tsx +135 -0
  24. package/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
  25. package/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
  26. package/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
  27. package/src/__tests__/MediaBrowser.test.tsx +353 -0
  28. package/src/__tests__/MediaField.test.tsx +185 -0
  29. package/src/__tests__/Notifications.test.tsx +69 -0
  30. package/src/__tests__/Onboarding.test.tsx +287 -0
  31. package/src/__tests__/cssTokens.test.ts +201 -0
  32. package/src/__tests__/fieldComponents.test.ts +43 -0
  33. package/src/__tests__/reorder.test.ts +58 -0
  34. package/src/components/ApplyChangesModal.tsx +348 -0
  35. package/src/components/BranchImport.tsx +157 -0
  36. package/src/components/BranchMenu.tsx +192 -0
  37. package/src/components/Button.tsx +131 -0
  38. package/src/components/ChangeDetail.tsx +472 -0
  39. package/src/components/ChangeRequestSummary.tsx +173 -0
  40. package/src/components/CollabField.tsx +388 -0
  41. package/src/components/ContentBrowser.tsx +5073 -0
  42. package/src/components/Icon.tsx +28 -0
  43. package/src/components/Icon.web.tsx +31 -0
  44. package/src/components/Input.tsx +106 -0
  45. package/src/components/MediaBrowser.tsx +766 -0
  46. package/src/components/MediaField.tsx +670 -0
  47. package/src/components/MediaPreview.tsx +91 -0
  48. package/src/components/MediaPreview.web.tsx +169 -0
  49. package/src/components/NavRow.tsx +105 -0
  50. package/src/components/Notifications.tsx +301 -0
  51. package/src/components/Onboarding.tsx +751 -0
  52. package/src/components/ProjectMenu.tsx +124 -0
  53. package/src/components/Segment.tsx +87 -0
  54. package/src/components/Skeleton.tsx +216 -0
  55. package/src/components/Spinner.tsx +44 -0
  56. package/src/components/Text.tsx +85 -0
  57. package/src/components/documentDrafts.ts +213 -0
  58. package/src/components/layout.tsx +284 -0
  59. package/src/components/primitives.tsx +143 -0
  60. package/src/components/reorder.ts +40 -0
  61. package/src/fieldComponents.ts +63 -0
  62. package/src/icons.ts +102 -0
  63. package/src/index.ts +198 -0
  64. package/src/media.ts +229 -0
  65. package/src/theme.ts +116 -0
  66. package/src/web/Button.tsx +110 -0
  67. package/src/web/Icon.tsx +52 -0
  68. package/src/web/Input.tsx +39 -0
  69. package/src/web/index.ts +43 -0
  70. package/src/web/primitives.tsx +119 -0
@@ -0,0 +1,142 @@
1
+ // A mixed list has to read the key the content actually uses.
2
+ //
3
+ // The control looked for `_variant` and nothing else. Content that names its own
4
+ // shapes — `type: hero`, written for the site's own templates long before any
5
+ // CMS — matched no variant, so every item rendered as a bare variant dropdown
6
+ // with none of its fields beneath it. On screen that is a list of empty rows,
7
+ // indistinguishable from a document with nothing in it.
8
+
9
+ import React from "react";
10
+ import { fireEvent, render, screen } from "@testing-library/react";
11
+ import { ThemeProvider } from "../ThemeProvider";
12
+ import {
13
+ ContentBrowser,
14
+ type CmsEntry,
15
+ type CmsNavSection,
16
+ type EntryField,
17
+ type EntrySaveChange,
18
+ } from "../components/ContentBrowser";
19
+ import { refreshDrafts } from "../components/documentDrafts";
20
+
21
+ jest.mock("../ThemeProvider", () => {
22
+ const actual = jest.requireActual("../ThemeProvider");
23
+ return { ...actual, useResponsive: () => ({ width: 1300, height: 900, isDesktop: true, isMobile: false }) };
24
+ });
25
+
26
+ const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
27
+ const sections: CmsNavSection[] = [
28
+ { title: "Content", items: [{ key: "pages", label: "Pages", icon: "newspaper" }] },
29
+ ];
30
+
31
+ beforeEach(() => {
32
+ localStorage.clear();
33
+ refreshDrafts();
34
+ });
35
+
36
+ const variants = [
37
+ {
38
+ name: "hero",
39
+ label: "Hero",
40
+ fields: [
41
+ { name: "title", label: "Title", type: "string", value: "" },
42
+ { name: "subtitle", label: "Subtitle", type: "string", value: "" },
43
+ ],
44
+ },
45
+ { name: "news", label: "News & Updates", fields: [{ name: "title", label: "Title", type: "string", value: "" }] },
46
+ ];
47
+
48
+ // The shape the content actually has: the variant named by `type`, no `_variant`
49
+ // anywhere.
50
+ const widgets = (discriminator?: string): EntryField => ({
51
+ name: "widgets",
52
+ label: "Widgets",
53
+ type: "array",
54
+ component: "mixedList",
55
+ discriminator,
56
+ value: [
57
+ { type: "hero", title: "Welcome", subtitle: "To our home" },
58
+ { type: "news", title: "Latest" },
59
+ ],
60
+ variants,
61
+ }) as EntryField;
62
+
63
+ function renderDoc(field: EntryField, onSaveEntry = jest.fn()) {
64
+ const entry: CmsEntry = { id: "a", path: "pages/home.json", title: "Home", body: "", fields: [field] };
65
+ render(
66
+ wrap(
67
+ <ContentBrowser
68
+ workspace={{ name: "acme/site", initials: "AC", branch: "main", changed: 0 }}
69
+ sections={sections}
70
+ activeNavKey="pages"
71
+ onSelectNav={() => {}}
72
+ entries={[entry]}
73
+ userInitials="ED"
74
+ onSaveEntry={onSaveEntry}
75
+ />
76
+ )
77
+ );
78
+ return onSaveEntry;
79
+ }
80
+
81
+ test("a field naming its own discriminator renders each item's fields", () => {
82
+ renderDoc(widgets("type"));
83
+
84
+ expect(screen.getByDisplayValue("Welcome")).toBeInTheDocument();
85
+ expect(screen.getByDisplayValue("To our home")).toBeInTheDocument();
86
+ expect(screen.getByDisplayValue("Latest")).toBeInTheDocument();
87
+ // Each item's dropdown shows which shape it is.
88
+ const pickers = screen.getAllByTestId("select-control") as HTMLSelectElement[];
89
+ expect(pickers.map((p) => p.value)).toEqual(["hero", "news"]);
90
+ });
91
+
92
+ test("without one, the same content matches no variant — the old behavior, on purpose", () => {
93
+ // Left as a test rather than deleted: it is what the default still does, and
94
+ // what every document written by this CMS relies on.
95
+ renderDoc(widgets(undefined));
96
+ expect(screen.queryByDisplayValue("Welcome")).not.toBeInTheDocument();
97
+ });
98
+
99
+ test("_variant stays the default for content that uses it", () => {
100
+ renderDoc({
101
+ ...widgets(undefined),
102
+ value: [{ _variant: "hero", title: "Welcome" }],
103
+ } as EntryField);
104
+ expect(screen.getByDisplayValue("Welcome")).toBeInTheDocument();
105
+ });
106
+
107
+ test("edits keep writing the key the content uses, not a second one", async () => {
108
+ const onSave = renderDoc(widgets("type"));
109
+
110
+ fireEvent.change(screen.getByDisplayValue("Welcome"), { target: { value: "Welcome home" } });
111
+ fireEvent.click(screen.getByTestId("save-entry"));
112
+
113
+ const change = (onSave.mock.calls[0] as [EntrySaveChange])[0];
114
+ expect(change.fields.widgets).toEqual([
115
+ { type: "hero", title: "Welcome home", subtitle: "To our home" },
116
+ { type: "news", title: "Latest" },
117
+ ]);
118
+ });
119
+
120
+ test("a new item is created under the field's own key", () => {
121
+ const onSave = renderDoc(widgets("type"));
122
+
123
+ fireEvent.click(screen.getByTestId("mixedlist-add"));
124
+ fireEvent.click(screen.getByTestId("save-entry"));
125
+
126
+ const change = (onSave.mock.calls[0] as [EntrySaveChange])[0];
127
+ const items = change.fields.widgets as Record<string, unknown>[];
128
+ expect(items[2]).toEqual({ type: "hero" });
129
+ expect(items[2]).not.toHaveProperty("_variant");
130
+ });
131
+
132
+ test("changing an item's variant rewrites that key rather than adding another", () => {
133
+ const onSave = renderDoc(widgets("type"));
134
+
135
+ const pickers = screen.getAllByTestId("select-control") as HTMLSelectElement[];
136
+ fireEvent.change(pickers[1], { target: { value: "hero" } });
137
+ fireEvent.click(screen.getByTestId("save-entry"));
138
+
139
+ const change = (onSave.mock.calls[0] as [EntrySaveChange])[0];
140
+ const items = change.fields.widgets as Record<string, unknown>[];
141
+ expect(items[1]).toEqual({ type: "hero" });
142
+ });
@@ -0,0 +1,271 @@
1
+ // Editing is explicit: edits accumulate as a local draft and reach the host
2
+ // only when the author saves.
3
+ //
4
+ // The behaviour these pin is the reason for the change — a CMS backed by git
5
+ // should not turn every keystroke into a commit — and the parts most likely to
6
+ // be broken by a later refactor are the ones about *not* losing work: a draft
7
+ // outlives the document being closed, and Discard is the only thing besides a
8
+ // successful save that throws it away.
9
+ import React from "react";
10
+ import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
11
+ import { ThemeProvider } from "../ThemeProvider";
12
+ import { ContentBrowser, type CmsEntry, type CmsNavSection, type EntryField } from "../components/ContentBrowser";
13
+ import { readDraft, refreshDrafts } from "../components/documentDrafts";
14
+
15
+ // Force the desktop layout (jsdom reports width 0 → mobile otherwise).
16
+ jest.mock("../ThemeProvider", () => {
17
+ const actual = jest.requireActual("../ThemeProvider");
18
+ return { ...actual, useResponsive: () => ({ width: 1300, height: 900, isDesktop: true, isMobile: false }) };
19
+ });
20
+
21
+ const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
22
+
23
+ const sections: CmsNavSection[] = [
24
+ { title: "Content", items: [{ key: "posts", label: "Posts", icon: "newspaper" }] },
25
+ ];
26
+
27
+ const fields = (title = "Alpha"): EntryField[] => [
28
+ { name: "title", label: "Title", type: "string", value: title, required: true },
29
+ { name: "summary", label: "Summary", type: "string", value: "" },
30
+ ];
31
+
32
+ const entry = (id = "doc-1", title = "Alpha"): CmsEntry => ({
33
+ id,
34
+ path: "posts/a.md",
35
+ title,
36
+ body: "",
37
+ fields: fields(title),
38
+ });
39
+
40
+ function renderBrowser(onSaveEntry = jest.fn(), e: CmsEntry = entry(), onEntryDraft?: jest.Mock) {
41
+ const base = {
42
+ workspace: { name: "acme/site", initials: "AC", branch: "main", changed: 0 },
43
+ sections,
44
+ activeNavKey: "posts",
45
+ onSelectNav: () => {},
46
+ entries: [e],
47
+ userInitials: "ED",
48
+ } as const;
49
+ const utils = render(
50
+ wrap(<ContentBrowser {...base} onSaveEntry={onSaveEntry} onEntryDraft={onEntryDraft} />),
51
+ );
52
+ return { ...utils, onSaveEntry, onEntryDraft };
53
+ }
54
+
55
+ // The draft write is debounced; these tests wait for it rather than racing it.
56
+ const DRAFT_SETTLE = 600;
57
+
58
+ function typeSummary(text: string) {
59
+ const input = screen.getByDisplayValue("") as HTMLInputElement;
60
+ fireEvent.change(input, { target: { value: text } });
61
+ }
62
+
63
+ beforeEach(() => {
64
+ // Clearing storage behind the store's back is exactly what refreshDrafts is
65
+ // for — without it the cached id set outlives the drafts it describes.
66
+ localStorage.clear();
67
+ refreshDrafts();
68
+ });
69
+
70
+ test("editing does not reach the host until the author saves", async () => {
71
+ const { onSaveEntry } = renderBrowser();
72
+
73
+ typeSummary("a first sentence");
74
+
75
+ // The old editor would have persisted this within a second. Waiting past that
76
+ // window is the point of the assertion.
77
+ await act(() => new Promise((r) => setTimeout(r, 1200)));
78
+ expect(onSaveEntry).not.toHaveBeenCalled();
79
+
80
+ fireEvent.click(screen.getByTestId("save-entry"));
81
+ await waitFor(() => expect(onSaveEntry).toHaveBeenCalledTimes(1));
82
+ expect(onSaveEntry.mock.calls[0][0].fields.summary).toBe("a first sentence");
83
+ });
84
+
85
+ test("an edit is written to storage so a closed tab does not lose it", async () => {
86
+ renderBrowser();
87
+ typeSummary("work in progress");
88
+
89
+ await waitFor(() => expect(readDraft("doc-1")?.values.summary).toBe("work in progress"), {
90
+ timeout: DRAFT_SETTLE,
91
+ });
92
+ });
93
+
94
+ test("reopening a document shows the unsaved edit, not the saved version", async () => {
95
+ const first = renderBrowser();
96
+ typeSummary("unsaved words");
97
+ await waitFor(() => expect(readDraft("doc-1")).not.toBeNull(), { timeout: DRAFT_SETTLE });
98
+ first.unmount();
99
+
100
+ // A fresh mount, as if the column had been closed and the document reopened.
101
+ renderBrowser();
102
+ expect(await screen.findByDisplayValue("unsaved words")).toBeInTheDocument();
103
+ expect(screen.getByTestId("autosave-indicator")).toHaveTextContent("Unsaved changes");
104
+ });
105
+
106
+ test("saving clears the draft, so the document reopens as saved", async () => {
107
+ const { onSaveEntry, unmount } = renderBrowser();
108
+ typeSummary("ready to publish");
109
+ await waitFor(() => expect(readDraft("doc-1")).not.toBeNull(), { timeout: DRAFT_SETTLE });
110
+
111
+ fireEvent.click(screen.getByTestId("save-entry"));
112
+ await waitFor(() => expect(onSaveEntry).toHaveBeenCalled());
113
+ await waitFor(() => expect(readDraft("doc-1")).toBeNull());
114
+
115
+ unmount();
116
+ renderBrowser();
117
+ // The host owns the saved values; with no draft the editor shows what it was
118
+ // given, which here is still the original empty summary.
119
+ expect(screen.getByDisplayValue("")).toBeInTheDocument();
120
+ });
121
+
122
+ test("a failed save keeps the draft", async () => {
123
+ const onSaveEntry = jest.fn().mockRejectedValue(new Error("the branch is protected"));
124
+ renderBrowser(onSaveEntry);
125
+ typeSummary("still mine");
126
+ await waitFor(() => expect(readDraft("doc-1")).not.toBeNull(), { timeout: DRAFT_SETTLE });
127
+
128
+ fireEvent.click(screen.getByTestId("save-entry"));
129
+ await waitFor(() => expect(screen.getByTestId("autosave-indicator")).toHaveTextContent("Save failed"));
130
+ // The work must survive the thing that went wrong.
131
+ expect(readDraft("doc-1")?.values.summary).toBe("still mine");
132
+ expect(screen.getByDisplayValue("still mine")).toBeInTheDocument();
133
+ });
134
+
135
+ test("discard drops the draft and restores the saved values", async () => {
136
+ renderBrowser();
137
+ typeSummary("never mind");
138
+ await waitFor(() => expect(readDraft("doc-1")).not.toBeNull(), { timeout: DRAFT_SETTLE });
139
+
140
+ fireEvent.click(screen.getByTestId("discard-changes"));
141
+
142
+ expect(readDraft("doc-1")).toBeNull();
143
+ expect(screen.queryByDisplayValue("never mind")).not.toBeInTheDocument();
144
+ expect(screen.queryByTestId("autosave-indicator")).not.toBeInTheDocument();
145
+ });
146
+
147
+ // The preview is fed from edits, and a discard is the one change to a document
148
+ // that produces no edit — so without an explicit emission the preview keeps
149
+ // rendering work the author just deleted.
150
+ test("discarding tells the preview to go back to the saved version", async () => {
151
+ const onEntryDraft = jest.fn();
152
+ renderBrowser(jest.fn(), entry(), onEntryDraft);
153
+
154
+ typeSummary("about to be discarded");
155
+ await waitFor(() => expect(onEntryDraft).toHaveBeenCalled(), { timeout: DRAFT_SETTLE });
156
+ expect(onEntryDraft.mock.calls.at(-1)![0].fields.summary).toBe("about to be discarded");
157
+
158
+ fireEvent.click(screen.getByTestId("discard-changes"));
159
+
160
+ await waitFor(
161
+ () => expect(onEntryDraft.mock.calls.at(-1)![0].fields.summary).toBe(""),
162
+ { timeout: DRAFT_SETTLE },
163
+ );
164
+ });
165
+
166
+ test("save is offered only when there is something valid to save", async () => {
167
+ renderBrowser();
168
+ const save = screen.getByTestId("save-entry");
169
+ // Nothing edited yet.
170
+ expect(save).toBeDisabled();
171
+
172
+ // A required field emptied: the draft is still kept (the author's work is not
173
+ // the validator's business) but saving it is refused.
174
+ const title = screen.getByDisplayValue("Alpha") as HTMLInputElement;
175
+ fireEvent.change(title, { target: { value: "" } });
176
+ expect(screen.getByTestId("save-entry")).toBeDisabled();
177
+ await waitFor(() => expect(readDraft("doc-1")?.values.title).toBe(""), { timeout: DRAFT_SETTLE });
178
+
179
+ fireEvent.change(title, { target: { value: "Alpha again" } });
180
+ await waitFor(() => expect(screen.getByTestId("save-entry")).not.toBeDisabled());
181
+ });
182
+
183
+ // The list is where an author sees work they left somewhere else: a document
184
+ // edited yesterday, or in another column, is otherwise indistinguishable from a
185
+ // saved one until it is opened.
186
+ test("a document with unsaved edits is badged in the list and counted in the header", async () => {
187
+ renderBrowser();
188
+
189
+ // Nothing pending: no badge, and no count to report.
190
+ expect(screen.queryByTestId("entry-draft-badge")).not.toBeInTheDocument();
191
+ expect(screen.queryByTestId("uncommitted-count")).not.toBeInTheDocument();
192
+
193
+ typeSummary("half a thought");
194
+
195
+ // Both appear on the first edit, without a reload — the list is subscribed to
196
+ // the draft store, not reading it once at mount.
197
+ expect(await screen.findByTestId("entry-draft-badge")).toBeInTheDocument();
198
+ expect(screen.getByTestId("uncommitted-count")).toHaveTextContent("1 uncommitted");
199
+ });
200
+
201
+ test("the badge and the count clear when the draft is discarded", async () => {
202
+ renderBrowser();
203
+ typeSummary("never mind this either");
204
+ expect(await screen.findByTestId("entry-draft-badge")).toBeInTheDocument();
205
+
206
+ fireEvent.click(screen.getByTestId("discard-changes"));
207
+
208
+ await waitFor(() => expect(screen.queryByTestId("entry-draft-badge")).not.toBeInTheDocument());
209
+ expect(screen.queryByTestId("uncommitted-count")).not.toBeInTheDocument();
210
+ });
211
+
212
+ test("the count survives a reload, because the drafts do", async () => {
213
+ const first = renderBrowser();
214
+ typeSummary("still here tomorrow");
215
+ await waitFor(() => expect(readDraft("doc-1")).not.toBeNull(), { timeout: DRAFT_SETTLE });
216
+ first.unmount();
217
+
218
+ renderBrowser();
219
+ expect(await screen.findByTestId("uncommitted-count")).toHaveTextContent("1 uncommitted");
220
+ expect(screen.getByTestId("entry-draft-badge")).toBeInTheDocument();
221
+ });
222
+
223
+ // Unsaved means "differs from what is saved". Both of these were reported as a
224
+ // document that opens dirty: the stored draft is the one every client left
225
+ // behind while a field could mark a document changed just by being written to.
226
+ test("a draft that matches the saved document is dropped, not offered back", async () => {
227
+ localStorage.setItem(
228
+ "gitcms.draft.v1:doc-1",
229
+ JSON.stringify({ v: 1, values: { title: "Alpha", summary: "" }, at: Date.now() }),
230
+ );
231
+ refreshDrafts();
232
+
233
+ renderBrowser();
234
+
235
+ expect(screen.queryByTestId("autosave-indicator")).not.toBeInTheDocument();
236
+ expect(screen.getByTestId("save-entry")).toBeDisabled();
237
+ await waitFor(() => expect(readDraft("doc-1")).toBeNull());
238
+ expect(screen.queryByTestId("entry-draft-badge")).not.toBeInTheDocument();
239
+ });
240
+
241
+ test("typing back to the saved value leaves nothing to save", async () => {
242
+ renderBrowser();
243
+ const title = screen.getByDisplayValue("Alpha") as HTMLInputElement;
244
+
245
+ fireEvent.change(title, { target: { value: "Alphabet" } });
246
+ expect(screen.getByTestId("autosave-indicator")).toHaveTextContent("Unsaved changes");
247
+ await waitFor(() => expect(readDraft("doc-1")).not.toBeNull(), { timeout: DRAFT_SETTLE });
248
+
249
+ fireEvent.change(title, { target: { value: "Alpha" } });
250
+
251
+ expect(screen.queryByTestId("autosave-indicator")).not.toBeInTheDocument();
252
+ expect(screen.getByTestId("save-entry")).toBeDisabled();
253
+ // The stored copy goes too, or the document is dirty again on the next reload.
254
+ await waitFor(() => expect(readDraft("doc-1")).toBeNull());
255
+ });
256
+
257
+ test("a read-only document offers no save and writes no draft", async () => {
258
+ const base = {
259
+ workspace: { name: "acme/site", initials: "AC", branch: "main", changed: 0 },
260
+ sections,
261
+ activeNavKey: "posts",
262
+ onSelectNav: () => {},
263
+ entries: [entry()],
264
+ userInitials: "ED",
265
+ readOnly: true,
266
+ } as const;
267
+ render(wrap(<ContentBrowser {...base} onSaveEntry={jest.fn()} />));
268
+
269
+ expect(screen.queryByTestId("save-entry")).not.toBeInTheDocument();
270
+ expect(readDraft("doc-1")).toBeNull();
271
+ });
@@ -0,0 +1,117 @@
1
+ import React from "react";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import { ThemeProvider } from "../ThemeProvider";
4
+ import { ContentBrowser, type CmsEntry, type CmsNavSection, type EntryField } from "../components/ContentBrowser";
5
+
6
+ // Force the desktop layout (jsdom reports width 0 → mobile otherwise).
7
+ jest.mock("../ThemeProvider", () => {
8
+ const actual = jest.requireActual("../ThemeProvider");
9
+ return { ...actual, useResponsive: () => ({ width: 1300, height: 900, isDesktop: true, isMobile: false }) };
10
+ });
11
+
12
+ const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
13
+
14
+ const sections: CmsNavSection[] = [
15
+ { title: "Content", items: [{ key: "posts", label: "Posts", icon: "newspaper" }] },
16
+ ];
17
+
18
+ const fields: EntryField[] = [
19
+ { name: "active", label: "Active", type: "boolean", component: "switch", value: false },
20
+ { name: "count", label: "Count", type: "integer", component: "stepper", min: 0, max: 5, value: "" },
21
+ { name: "status", label: "Status", type: "string", component: "select", enumValues: ["draft", "published"], value: "" },
22
+ { name: "publishedAt", label: "Published", type: "string", component: "date", value: "" },
23
+ { name: "tags", label: "Tags", type: "array", component: "list", of: "string", value: [] },
24
+ { name: "meta", label: "Meta", type: "object", component: "group", value: {}, fields: [{ name: "author", label: "Author", type: "string", value: "" }] },
25
+ {
26
+ name: "blocks", label: "Blocks", type: "array", component: "mixedList", value: [],
27
+ variants: [{ name: "hero", label: "Hero", fields: [{ name: "heading", label: "Heading", type: "string", value: "" }] }],
28
+ },
29
+ ];
30
+
31
+ const entry: CmsEntry = { id: "a", path: "posts/a.md", title: "Alpha", body: "", fields };
32
+
33
+ // No onSelectEntry / entriesEmpty → uncontrolled desktop mode auto-selects the
34
+ // first entry, so its field editor renders without a click.
35
+ const base = {
36
+ workspace: { name: "acme/site", initials: "AC", branch: "main", changed: 0 },
37
+ sections,
38
+ activeNavKey: "posts",
39
+ onSelectNav: () => {},
40
+ entries: [entry],
41
+ userInitials: "ED",
42
+ } as const;
43
+
44
+ function renderBrowser() {
45
+ return render(wrap(<ContentBrowser {...base} onSaveEntry={jest.fn()} />));
46
+ }
47
+
48
+ test("switch renders and toggles its checked state", () => {
49
+ renderBrowser();
50
+ const sw = screen.getByRole("switch");
51
+ expect(sw).toHaveAttribute("aria-checked", "false");
52
+ fireEvent.click(sw);
53
+ expect(sw).toHaveAttribute("aria-checked", "true");
54
+ });
55
+
56
+ test("stepper increments and clamps at the configured max", () => {
57
+ renderBrowser();
58
+ // From empty, one increment → 1.
59
+ fireEvent.click(screen.getByTestId("stepper-inc"));
60
+ expect(screen.getByDisplayValue("1")).toBeInTheDocument();
61
+ // Push past max (5) — the value clamps.
62
+ for (let i = 0; i < 10; i++) fireEvent.click(screen.getByTestId("stepper-inc"));
63
+ expect(screen.getByDisplayValue("5")).toBeInTheDocument();
64
+ });
65
+
66
+ test("select renders a constrained dropdown with a blank option for the optional field", () => {
67
+ renderBrowser();
68
+ const select = screen.getByTestId("select-control") as HTMLSelectElement;
69
+ // draft + published + one blank (—) because the field is optional.
70
+ expect(select.querySelectorAll("option")).toHaveLength(3);
71
+ });
72
+
73
+ test("date renders a native date input", () => {
74
+ renderBrowser();
75
+ expect(screen.getByTestId("date-control")).toHaveAttribute("type", "date");
76
+ });
77
+
78
+ test("list adds and removes items, respecting the add control", () => {
79
+ renderBrowser();
80
+ expect(screen.queryByTestId("list-remove-0")).not.toBeInTheDocument();
81
+ fireEvent.click(screen.getByTestId("list-add"));
82
+ expect(screen.getByTestId("list-remove-0")).toBeInTheDocument();
83
+ fireEvent.click(screen.getByTestId("list-remove-0"));
84
+ expect(screen.queryByTestId("list-remove-0")).not.toBeInTheDocument();
85
+ });
86
+
87
+ test("group pushes an in-column view and the back control returns", () => {
88
+ renderBrowser();
89
+ // The nested "author" field isn't visible until the group is opened.
90
+ expect(screen.queryByText("Author")).not.toBeInTheDocument();
91
+ fireEvent.click(screen.getByTestId("group-open-meta"));
92
+ expect(screen.getByTestId("group-back")).toBeInTheDocument();
93
+ expect(screen.getByText("Author")).toBeInTheDocument();
94
+ fireEvent.click(screen.getByTestId("group-back"));
95
+ expect(screen.queryByTestId("group-back")).not.toBeInTheDocument();
96
+ expect(screen.queryByText("Author")).not.toBeInTheDocument();
97
+ });
98
+
99
+ test("the field body scrolls while the header stays outside the scroll region", () => {
100
+ renderBrowser();
101
+ const scroll = screen.getByTestId("column-scroll");
102
+ // A field control lives inside the scrollable body...
103
+ expect(scroll).toContainElement(screen.getByTestId("date-control"));
104
+ // ...but the sticky header's close control does not.
105
+ expect(scroll).not.toContainElement(screen.getByTestId("column-close"));
106
+ });
107
+
108
+ test("mixed list adds an item (with its variant subform)", () => {
109
+ renderBrowser();
110
+ // The `tags` list is empty, so no item frame exists yet.
111
+ expect(screen.queryByTestId("list-remove-0")).not.toBeInTheDocument();
112
+ fireEvent.click(screen.getByTestId("mixedlist-add"));
113
+ // The new mixed-list item renders an ItemFrame (shared remove control) and the
114
+ // hero variant's "Heading" field.
115
+ expect(screen.getByTestId("list-remove-0")).toBeInTheDocument();
116
+ expect(screen.getByText("Heading")).toBeInTheDocument();
117
+ });
@@ -0,0 +1,140 @@
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 { MediaApi, MediaAsset, MediaResolution } from "../media";
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 hero: MediaAsset = {
14
+ id: "m1",
15
+ set: "uploads",
16
+ repoPath: "public/uploads/hero.png",
17
+ publicPath: "/uploads/hero.png",
18
+ cdnPath: null,
19
+ mimeType: "image/png",
20
+ extension: "png",
21
+ kind: "image",
22
+ size: 2048,
23
+ width: 800,
24
+ height: 600,
25
+ pending: false,
26
+ url: "https://store.example.com/signed/hero.png",
27
+ };
28
+
29
+ function makeApi(over: Partial<MediaApi> = {}): MediaApi {
30
+ return {
31
+ sets: [{ name: "uploads", path: "public/uploads/**/*", mimeTypes: [], count: 1 }],
32
+ resolve: jest.fn(async (paths: string[]): Promise<MediaResolution[]> =>
33
+ paths.map((p) => ({ input: p, media: hero, aliasKind: "public", ambiguous: false, candidates: [] })),
34
+ ),
35
+ byId: jest.fn(async () => hero),
36
+ list: jest.fn(async () => [hero]),
37
+ ...over,
38
+ };
39
+ }
40
+
41
+ const sections: CmsNavSection[] = [
42
+ { title: "Content", items: [{ key: "posts", label: "Posts", icon: "newspaper" }] },
43
+ ];
44
+
45
+ function renderWith(fields: EntryField[], api: MediaApi | undefined = makeApi()) {
46
+ const onSave = jest.fn();
47
+ const entry: CmsEntry = { id: "a", path: "content/posts/a.md", title: "Alpha", body: "", fields };
48
+ render(
49
+ <ThemeProvider>
50
+ <ContentBrowser
51
+ workspace={{ name: "acme/site", initials: "AC", branch: "main", changed: 0 }}
52
+ sections={sections}
53
+ activeNavKey="posts"
54
+ onSelectNav={() => {}}
55
+ entries={[entry]}
56
+ userInitials="ED"
57
+ media={api}
58
+ onSaveEntry={onSave}
59
+ />
60
+ </ThemeProvider>,
61
+ );
62
+ return onSave;
63
+ }
64
+
65
+ const galleryField: EntryField = {
66
+ name: "gallery",
67
+ label: "Gallery",
68
+ type: "array",
69
+ of: "string",
70
+ component: "list",
71
+ media: "uploads",
72
+ storeAs: "public",
73
+ value: ["/uploads/hero.png"],
74
+ };
75
+
76
+ // The gap this closes: an array's items used to be rebuilt with only
77
+ // name/type/fields, dropping the display config — so a list of media rendered as
78
+ // plain text inputs even though the config asked for pickers.
79
+ test("array items render media pickers when the array names a media set", async () => {
80
+ const api = makeApi();
81
+ renderWith([galleryField], api);
82
+
83
+ const img = await screen.findByRole("img");
84
+ expect(img).toHaveAttribute("src", hero.url);
85
+ expect(screen.getByTestId("media-choose")).toBeInTheDocument();
86
+ // The array's own component is `list` — it must not be handed to the item as
87
+ // if it were a control the item could render.
88
+ expect(api.resolve).toHaveBeenCalledWith(["/uploads/hero.png"], "content/posts/a.md");
89
+ });
90
+
91
+ test("a list with no media set stays a plain list of inputs", () => {
92
+ renderWith([{ ...galleryField, media: undefined, storeAs: undefined, value: ["x"] }]);
93
+ expect(screen.queryByTestId("media-choose")).not.toBeInTheDocument();
94
+ });
95
+
96
+ test("each array item gets its own picker", async () => {
97
+ renderWith([{ ...galleryField, value: ["/uploads/hero.png", "/uploads/hero.png"] }]);
98
+ expect(await screen.findAllByTestId("media-choose")).toHaveLength(2);
99
+ });
100
+
101
+ test("picking into an array item writes that slot in the store_as form", async () => {
102
+ const onSave = renderWith([{ ...galleryField, value: ["", ""] }]);
103
+
104
+ const choosers = await screen.findAllByTestId("media-choose");
105
+ fireEvent.click(choosers[1]);
106
+ fireEvent.click(await screen.findByTestId("media-row-m1"));
107
+
108
+ // Editing no longer reaches the host on its own — the author saves.
109
+ fireEvent.click(await screen.findByTestId("save-entry"));
110
+
111
+ // The pick must land in the second slot and leave the first alone — a picker
112
+ // wired to the wrong index would silently overwrite a neighbouring image.
113
+ await waitFor(() => {
114
+ expect(onSave).toHaveBeenCalled();
115
+ const change = onSave.mock.calls[onSave.mock.calls.length - 1][0];
116
+ expect(change.fields.gallery).toEqual(["", "/uploads/hero.png"]);
117
+ });
118
+ });
119
+
120
+ // Media fields inside a group/object are reached through the same context, not a
121
+ // drilled prop, so the document path still has to arrive.
122
+ test("a media field nested in an object resolves against the document path", async () => {
123
+ const api = makeApi();
124
+ renderWith(
125
+ [
126
+ {
127
+ name: "author",
128
+ label: "Author",
129
+ type: "object",
130
+ component: "inlineGroup",
131
+ value: { avatar: "/uploads/hero.png" },
132
+ fields: [{ name: "avatar", type: "string", component: "media", media: "uploads", value: "" }],
133
+ },
134
+ ],
135
+ api,
136
+ );
137
+
138
+ await screen.findByRole("img");
139
+ expect(api.resolve).toHaveBeenCalledWith(["/uploads/hero.png"], "content/posts/a.md");
140
+ });