@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,63 @@
1
+ import React from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { ThemeProvider } from "../ThemeProvider";
4
+ import { ContentBrowser, type CmsEntry, type CmsNavSection, type CollabApi, type CollabRegister, type CollabText, type OpenTab } from "../components/ContentBrowser";
5
+
6
+ jest.mock("../ThemeProvider", () => {
7
+ const actual = jest.requireActual("../ThemeProvider");
8
+ return { ...actual, useResponsive: () => ({ width: 1300, height: 900, isDesktop: true, isMobile: false }) };
9
+ });
10
+
11
+ const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
12
+
13
+ function fakeCollab(seed: Record<string, unknown> = {}) {
14
+ const values = new Map<string, unknown>(Object.entries(seed));
15
+ const listeners = new Map<string, Set<() => void>>();
16
+ const noPeers: never[] = [];
17
+ const notify = (p: string) => listeners.get(p)?.forEach((l) => l());
18
+ const listen = (p: string, cb: () => void) => {
19
+ let s = listeners.get(p);
20
+ if (!s) listeners.set(p, (s = new Set()));
21
+ s.add(cb);
22
+ return () => void s!.delete(cb);
23
+ };
24
+ const textCache = new Map<string, CollabText>();
25
+ const text = (p: string): CollabText => {
26
+ let b = textCache.get(p);
27
+ if (!b) textCache.set(p, (b = { get: () => String(values.get(p) ?? ""), set: (n) => { values.set(p, n); notify(p); }, peers: () => noPeers, subscribe: (cb) => listen(p, cb) }));
28
+ return b;
29
+ };
30
+ const regCache = new Map<string, CollabRegister>();
31
+ const register = (p: string): CollabRegister => {
32
+ let b = regCache.get(p);
33
+ if (!b) regCache.set(p, (b = { get: () => values.get(p), set: (n) => { values.set(p, n); notify(p); }, subscribe: (cb) => listen(p, cb) }));
34
+ return b;
35
+ };
36
+ const api: CollabApi = {
37
+ text, register, setFocus: () => {}, peersAt: () => noPeers, peersUnder: () => noPeers,
38
+ participants: () => noPeers, awarenessVersion: () => 0, subscribeAwareness: () => () => {},
39
+ ydoc: {}, awareness: {},
40
+ };
41
+ return api;
42
+ }
43
+
44
+ const sections: CmsNavSection[] = [{ title: "Content", items: [{ key: "posts", label: "Posts", icon: "newspaper" }] }];
45
+ const base = {
46
+ workspace: { name: "acme/site", initials: "AC", branch: "main", changed: 0 },
47
+ sections, activeNavKey: "posts", onSelectNav: () => {},
48
+ entries: [{ id: "a", path: "posts/a.md", title: "Alpha", body: "", fields: [] } as CmsEntry],
49
+ userInitials: "ED",
50
+ } as const;
51
+
52
+ const blocksField = {
53
+ name: "blocks", label: "Blocks", type: "array", component: "mixedList",
54
+ value: [{ _variant: "hero", heading: "Welcome" }],
55
+ variants: [{ name: "hero", label: "Hero", fields: [{ name: "heading", label: "Heading", type: "string", value: "" }] }],
56
+ };
57
+
58
+ test("mixed list renders its existing values under collab", () => {
59
+ const collab = fakeCollab();
60
+ const entry: CmsEntry = { id: "a", path: "posts/a.md", title: "Alpha", body: "", collab, fields: [blocksField as any] };
61
+ render(wrap(<ContentBrowser {...base} openTabs={[{ id: "a", collection: "posts", entry } as OpenTab]} activeTabId="a" onSaveEntry={jest.fn()} />));
62
+ expect(screen.getByDisplayValue("Welcome")).toBeInTheDocument();
63
+ });
@@ -0,0 +1,38 @@
1
+ import React from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { ThemeProvider } from "../ThemeProvider";
4
+ import { ContentBrowser, type CmsEntry, type CmsNavSection, type EntryField } from "../components/ContentBrowser";
5
+
6
+ jest.mock("../ThemeProvider", () => {
7
+ const actual = jest.requireActual("../ThemeProvider");
8
+ return { ...actual, useResponsive: () => ({ width: 1300, height: 900, isDesktop: true, isMobile: false }) };
9
+ });
10
+
11
+ const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
12
+ const sections: CmsNavSection[] = [{ title: "Content", items: [{ key: "posts", label: "Posts", icon: "newspaper" }] }];
13
+
14
+ const fields: EntryField[] = [
15
+ {
16
+ name: "blocks", label: "Blocks", type: "array", component: "mixedList",
17
+ value: [
18
+ { _variant: "hero", heading: "Welcome" },
19
+ { _variant: "metrics", items: [{ label: "Deploys", value: "14" }] },
20
+ ],
21
+ variants: [
22
+ { name: "hero", label: "Hero", fields: [{ name: "heading", label: "Heading", type: "string", value: "" }] },
23
+ { name: "metrics", label: "Metrics", fields: [{ name: "items", label: "Items", type: "array", component: "list", of: "object", value: [], fields: [{ name: "label", label: "Label", type: "string", value: "" }, { name: "value", label: "Value", type: "string", value: "" }] }] },
24
+ ],
25
+ },
26
+ ];
27
+
28
+ const entry: CmsEntry = { id: "a", path: "posts/a.md", title: "Alpha", body: "", fields };
29
+ const base = {
30
+ workspace: { name: "acme/site", initials: "AC", branch: "main", changed: 0 },
31
+ sections, activeNavKey: "posts", onSelectNav: () => {}, entries: [entry], userInitials: "ED",
32
+ } as const;
33
+
34
+ test("existing mixed-list values render", () => {
35
+ render(wrap(<ContentBrowser {...base} onSaveEntry={jest.fn()} />));
36
+ screen.debug(screen.getByTestId("column-scroll"), 100000);
37
+ expect(screen.getByDisplayValue("Welcome")).toBeInTheDocument();
38
+ });
@@ -0,0 +1,62 @@
1
+ import React from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { ThemeProvider } from "../ThemeProvider";
4
+ import { ContentBrowser, type CmsEntry, type CmsNavSection } from "../components/ContentBrowser";
5
+
6
+ // react-native-web reports a 0-width viewport under jsdom, which would select the
7
+ // mobile layout. Force the desktop breakpoint so these tests exercise the desktop
8
+ // entry list (its FlatList, count header, and pagination footer).
9
+ jest.mock("../ThemeProvider", () => {
10
+ const actual = jest.requireActual("../ThemeProvider");
11
+ return { ...actual, useResponsive: () => ({ width: 1300, height: 900, isDesktop: true, isMobile: false }) };
12
+ });
13
+
14
+ const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
15
+
16
+ const sections: CmsNavSection[] = [
17
+ { title: "Content", items: [{ key: "posts", label: "Posts", icon: "newspaper" }] },
18
+ ];
19
+
20
+ // A single loaded page of a larger collection (3 of 100).
21
+ const entries: CmsEntry[] = [
22
+ { id: "a", path: "posts/a.md", title: "Alpha", body: "" },
23
+ { id: "b", path: "posts/b.md", title: "Bravo", body: "" },
24
+ { id: "c", path: "posts/c.md", title: "Charlie", body: "" },
25
+ ];
26
+
27
+ const base = {
28
+ workspace: { name: "acme/site", initials: "AC", branch: "main", changed: 0 },
29
+ sections,
30
+ activeNavKey: "posts",
31
+ onSelectNav: () => {},
32
+ entries,
33
+ userInitials: "ED",
34
+ entriesEmpty: "Choose a collection",
35
+ onSelectEntry: () => {},
36
+ } as const;
37
+
38
+ test("the entry list renders as a FlatList showing the loaded page's rows", () => {
39
+ render(wrap(<ContentBrowser {...base} totalEntries={100} hasMoreEntries onLoadMoreEntries={() => {}} />));
40
+ expect(screen.getByTestId("entries-list")).toBeInTheDocument();
41
+ expect(screen.getByText("Alpha")).toBeInTheDocument();
42
+ expect(screen.getByText("Bravo")).toBeInTheDocument();
43
+ expect(screen.getByText("Charlie")).toBeInTheDocument();
44
+ });
45
+
46
+ test("the count header reflects the collection total, not the loaded page size", () => {
47
+ render(wrap(<ContentBrowser {...base} totalEntries={100} hasMoreEntries onLoadMoreEntries={() => {}} />));
48
+ // 100 total documents even though only one page (3 rows) is loaded.
49
+ expect(screen.getByText(/100 entries/)).toBeInTheDocument();
50
+ });
51
+
52
+ test("a footer spinner shows only while a page is loading", () => {
53
+ const { rerender } = render(
54
+ wrap(<ContentBrowser {...base} totalEntries={100} hasMoreEntries loadingMoreEntries={false} onLoadMoreEntries={() => {}} />),
55
+ );
56
+ expect(screen.queryByTestId("entries-loading-more")).not.toBeInTheDocument();
57
+
58
+ rerender(
59
+ wrap(<ContentBrowser {...base} totalEntries={100} hasMoreEntries loadingMoreEntries onLoadMoreEntries={() => {}} />),
60
+ );
61
+ expect(screen.getByTestId("entries-loading-more")).toBeInTheDocument();
62
+ });
@@ -0,0 +1,212 @@
1
+ import React from "react";
2
+ import { act, fireEvent, render, screen } from "@testing-library/react";
3
+ import { Text } from "../components/Text";
4
+ import { ThemeProvider } from "../ThemeProvider";
5
+ import {
6
+ ContentBrowser,
7
+ type CmsEntry,
8
+ type CmsNavSection,
9
+ type OpenTab,
10
+ } from "../components/ContentBrowser";
11
+
12
+ // Force the desktop layout (jsdom reports width 0 → mobile otherwise).
13
+ jest.mock("../ThemeProvider", () => {
14
+ const actual = jest.requireActual("../ThemeProvider");
15
+ return { ...actual, useResponsive: () => ({ width: 1300, height: 900, isDesktop: true, isMobile: false }) };
16
+ });
17
+
18
+ const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
19
+
20
+ const sections: CmsNavSection[] = [
21
+ { title: "Content", items: [{ key: "posts", label: "Posts", icon: "newspaper" }] },
22
+ ];
23
+ const entry: CmsEntry = { id: "a", path: "posts/a.md", title: "Alpha", body: "", fields: [] };
24
+
25
+ // A document with a real editable field + a body-sourced one, for the draft tests.
26
+ const editable: CmsEntry = {
27
+ id: "a",
28
+ path: "posts/a.md",
29
+ title: "Alpha",
30
+ body: "hello",
31
+ fields: [
32
+ { name: "title", type: "string", label: "Title", value: "Alpha" },
33
+ { name: "content", type: "string", source: "body", value: "hello" },
34
+ ],
35
+ };
36
+
37
+ const base = {
38
+ workspace: { name: "acme/site", initials: "AC", branch: "main", changed: 0 },
39
+ sections,
40
+ activeNavKey: "posts",
41
+ onSelectNav: () => {},
42
+ entries: [entry],
43
+ userInitials: "ED",
44
+ } as const;
45
+
46
+ const docTab: OpenTab = { id: "a", collection: "posts", entry };
47
+
48
+ describe("custom columns", () => {
49
+ it("renders a custom tab's content beside a document column", () => {
50
+ const tabs: OpenTab[] = [
51
+ docTab,
52
+ { id: "preview:a", collection: "posts", title: "Preview", content: <Text testID="the-preview">iframe</Text> },
53
+ ];
54
+ render(wrap(<ContentBrowser {...base} openTabs={tabs} activeTabId="a" />));
55
+
56
+ expect(screen.getByTestId("column-a")).toBeInTheDocument();
57
+ expect(screen.getByTestId("the-preview")).toBeInTheDocument();
58
+ expect(screen.getByText("Preview")).toBeInTheDocument();
59
+ });
60
+
61
+ it("gives a custom column the same close control as a document column", () => {
62
+ const onCloseTab = jest.fn();
63
+ const tabs: OpenTab[] = [
64
+ { id: "preview:a", collection: "posts", title: "Preview", content: <Text testID="the-preview">x</Text> },
65
+ ];
66
+ render(wrap(<ContentBrowser {...base} openTabs={tabs} activeTabId="preview:a" onCloseTab={onCloseTab} />));
67
+
68
+ screen.getByTestId("column-close").click();
69
+ expect(onCloseTab).toHaveBeenCalledWith("preview:a");
70
+ });
71
+
72
+ it("does not render the document editor for a custom column", () => {
73
+ const tabs: OpenTab[] = [
74
+ { id: "preview:a", collection: "posts", title: "Preview", content: <Text testID="the-preview">x</Text> },
75
+ ];
76
+ render(wrap(<ContentBrowser {...base} openTabs={tabs} activeTabId="preview:a" />));
77
+ // No breadcrumb path, which is the document header's tell.
78
+ expect(screen.queryByText("posts")).not.toBeInTheDocument();
79
+ });
80
+
81
+ it("leaves a plain document tab untouched when no custom tabs exist", () => {
82
+ render(wrap(<ContentBrowser {...base} openTabs={[docTab]} activeTabId="a" />));
83
+ expect(screen.getByTestId("column-a")).toBeInTheDocument();
84
+ expect(screen.queryByTestId("column-custom-body")).not.toBeInTheDocument();
85
+ });
86
+ });
87
+
88
+ describe("documentActions", () => {
89
+ it("renders host-supplied controls in an open document's header", () => {
90
+ render(
91
+ wrap(
92
+ <ContentBrowser
93
+ {...base}
94
+ openTabs={[docTab]}
95
+ activeTabId="a"
96
+ documentActions={(e) => <Text testID="action">Preview {e.title}</Text>}
97
+ />
98
+ )
99
+ );
100
+ expect(screen.getByTestId("action")).toHaveTextContent("Preview Alpha");
101
+ });
102
+
103
+ it("is called with the document, so buttons can vary per entry", () => {
104
+ const documentActions = jest.fn(() => null);
105
+ render(wrap(<ContentBrowser {...base} openTabs={[docTab]} activeTabId="a" documentActions={documentActions} />));
106
+ expect(documentActions).toHaveBeenCalledWith(expect.objectContaining({ id: "a", title: "Alpha" }));
107
+ });
108
+
109
+ it("renders nothing extra when omitted", () => {
110
+ render(wrap(<ContentBrowser {...base} openTabs={[docTab]} activeTabId="a" />));
111
+ expect(screen.queryByTestId("action")).not.toBeInTheDocument();
112
+ });
113
+
114
+ it("is not called for a custom column, which has no document", () => {
115
+ const documentActions = jest.fn(() => null);
116
+ render(
117
+ wrap(
118
+ <ContentBrowser
119
+ {...base}
120
+ openTabs={[{ id: "preview:a", collection: "posts", title: "Preview", content: <Text>x</Text> }]}
121
+ activeTabId="preview:a"
122
+ documentActions={documentActions}
123
+ />
124
+ )
125
+ );
126
+ expect(documentActions).not.toHaveBeenCalled();
127
+ });
128
+ });
129
+
130
+ describe("onEntryDraft", () => {
131
+ const draftTab: OpenTab = { id: "a", collection: "posts", entry: editable };
132
+
133
+ beforeEach(() => jest.useFakeTimers());
134
+ afterEach(() => jest.useRealTimers());
135
+
136
+ // The title control is the first text input in the column. Captured once and
137
+ // reused: re-querying by display value would stop matching the moment the
138
+ // first keystroke changed it.
139
+ const titleInput = () => screen.getAllByRole("textbox")[0];
140
+ const typeTitle = (input: HTMLElement, value: string) => {
141
+ act(() => void fireEvent.change(input, { target: { value } }));
142
+ };
143
+
144
+ it("emits the in-flight document, debounced", () => {
145
+ const onEntryDraft = jest.fn();
146
+ render(wrap(<ContentBrowser {...base} openTabs={[draftTab]} activeTabId="a" onEntryDraft={onEntryDraft} />));
147
+
148
+ typeTitle(titleInput(), "Alpha edited");
149
+ expect(onEntryDraft).not.toHaveBeenCalled(); // still inside the debounce
150
+ act(() => void jest.advanceTimersByTime(200));
151
+
152
+ expect(onEntryDraft).toHaveBeenCalledTimes(1);
153
+ expect(onEntryDraft.mock.calls[0][0]).toEqual({
154
+ id: "a",
155
+ fields: { title: "Alpha edited" },
156
+ // The body-sourced field is separated out, exactly as a save sends it —
157
+ // so a consumer never writes the article into the frontmatter.
158
+ body: "hello",
159
+ });
160
+ });
161
+
162
+ it("coalesces a burst of keystrokes into one draft", () => {
163
+ const onEntryDraft = jest.fn();
164
+ render(wrap(<ContentBrowser {...base} openTabs={[draftTab]} activeTabId="a" onEntryDraft={onEntryDraft} />));
165
+
166
+ const input = titleInput();
167
+ typeTitle(input, "A");
168
+ act(() => void jest.advanceTimersByTime(50));
169
+ typeTitle(input, "Al");
170
+ act(() => void jest.advanceTimersByTime(50));
171
+ typeTitle(input, "Alp");
172
+ act(() => void jest.advanceTimersByTime(200));
173
+
174
+ expect(onEntryDraft).toHaveBeenCalledTimes(1);
175
+ expect(onEntryDraft.mock.calls[0][0].fields).toEqual({ title: "Alp" });
176
+ });
177
+
178
+ it("emits for an invalid document too, so the preview keeps up mid-edit", () => {
179
+ const onEntryDraft = jest.fn();
180
+ const required: CmsEntry = {
181
+ ...editable,
182
+ fields: [{ name: "title", type: "string", label: "Title", value: "Alpha", required: true }],
183
+ };
184
+ render(
185
+ wrap(
186
+ <ContentBrowser
187
+ {...base}
188
+ openTabs={[{ id: "a", collection: "posts", entry: required }]}
189
+ activeTabId="a"
190
+ onSaveEntry={jest.fn()}
191
+ onEntryDraft={onEntryDraft}
192
+ />
193
+ )
194
+ );
195
+
196
+ typeTitle(titleInput(), ""); // now failing its required rule
197
+ act(() => void jest.advanceTimersByTime(200));
198
+ expect(onEntryDraft).toHaveBeenCalledTimes(1);
199
+ expect(onEntryDraft.mock.calls[0][0].fields).toEqual({ title: "" });
200
+ });
201
+
202
+ it("does not fire a pending draft after the column unmounts", () => {
203
+ const onEntryDraft = jest.fn();
204
+ const { unmount } = render(
205
+ wrap(<ContentBrowser {...base} openTabs={[draftTab]} activeTabId="a" onEntryDraft={onEntryDraft} />)
206
+ );
207
+ typeTitle(titleInput(), "Alpha edited");
208
+ unmount();
209
+ act(() => void jest.advanceTimersByTime(500));
210
+ expect(onEntryDraft).not.toHaveBeenCalled();
211
+ });
212
+ });
@@ -0,0 +1,45 @@
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 } 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
+ const entries: CmsEntry[] = [
18
+ { id: "a", path: "posts/a.md", title: "Alpha", body: "" },
19
+ { id: "b", path: "posts/b.md", title: "Beta", body: "" },
20
+ ];
21
+
22
+ const base = {
23
+ workspace: { name: "acme/site", initials: "AC", branch: "main", changed: 0 },
24
+ sections,
25
+ activeNavKey: "posts",
26
+ onSelectNav: () => {},
27
+ entries,
28
+ userInitials: "ED",
29
+ } as const;
30
+
31
+ test("both pane resize seams render on desktop", () => {
32
+ render(wrap(<ContentBrowser {...base} />));
33
+ expect(screen.getByTestId("resize-nav")).toBeInTheDocument();
34
+ expect(screen.getByTestId("resize-list")).toBeInTheDocument();
35
+ });
36
+
37
+ test("column reorder grips appear only once a second column is open", () => {
38
+ render(wrap(<ContentBrowser {...base} />));
39
+ // The first entry auto-selects into a single column — nothing to reorder yet.
40
+ expect(screen.queryByTestId("col-grip-a")).not.toBeInTheDocument();
41
+ // Shift-click opens the second entry in a NEW column (the reorder chord).
42
+ fireEvent.click(screen.getByText("Beta"), { shiftKey: true });
43
+ expect(screen.getByTestId("col-grip-a")).toBeInTheDocument();
44
+ expect(screen.getByTestId("col-grip-b")).toBeInTheDocument();
45
+ });
@@ -0,0 +1,135 @@
1
+ import React from "react";
2
+ import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
3
+ import { ThemeProvider } from "../ThemeProvider";
4
+ import { ContentBrowser, type CmsEntry, type CmsNavSection, type FacetField } 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
+ const entries: CmsEntry[] = [{ id: "a", path: "posts/a.md", title: "Alpha", body: "" }];
18
+ const facetFields: FacetField[] = [
19
+ // component "select" → enum chips; a plain enum (component "input") stays text.
20
+ { name: "status", label: "Status", type: "string", component: "select", enumValues: ["draft", "published"] },
21
+ { name: "title", label: "Title", type: "string", component: "input" },
22
+ { name: "publishedAt", label: "Published At", type: "string", component: "date" },
23
+ ];
24
+
25
+ const base = {
26
+ workspace: { name: "acme/site", initials: "AC", branch: "main", changed: 0 },
27
+ sections,
28
+ activeNavKey: "posts",
29
+ onSelectNav: () => {},
30
+ entries,
31
+ userInitials: "ED",
32
+ entriesEmpty: "Choose a collection",
33
+ onSelectEntry: () => {},
34
+ searchable: true,
35
+ facetFields,
36
+ } as const;
37
+
38
+ test("the magnifier opens a title-search input; the X clears it and restores the header", () => {
39
+ const onClearSearch = jest.fn();
40
+ render(wrap(<ContentBrowser {...base} onSearchChange={() => {}} onClearSearch={onClearSearch} />));
41
+
42
+ // Normal header: no input yet.
43
+ expect(screen.queryByTestId("search-input")).not.toBeInTheDocument();
44
+ fireEvent.click(screen.getByTestId("search-open"));
45
+ expect(screen.getByTestId("search-input")).toBeInTheDocument();
46
+
47
+ fireEvent.click(screen.getByTestId("search-close"));
48
+ expect(onClearSearch).toHaveBeenCalledTimes(1);
49
+ expect(screen.queryByTestId("search-input")).not.toBeInTheDocument();
50
+ // Header restored: the search icon is back.
51
+ expect(screen.getByTestId("search-open")).toBeInTheDocument();
52
+ });
53
+
54
+ test("typing debounces a title query to the host", () => {
55
+ jest.useFakeTimers();
56
+ try {
57
+ const onSearchChange = jest.fn();
58
+ render(wrap(<ContentBrowser {...base} onSearchChange={onSearchChange} onClearSearch={() => {}} />));
59
+ fireEvent.click(screen.getByTestId("search-open"));
60
+ act(() => { jest.advanceTimersByTime(300); }); // flush the initial "" push
61
+ onSearchChange.mockClear();
62
+
63
+ fireEvent.change(screen.getByTestId("search-input"), { target: { value: "alph" } });
64
+ // Not sent immediately (debounced)…
65
+ expect(onSearchChange).not.toHaveBeenCalled();
66
+ act(() => { jest.advanceTimersByTime(250); });
67
+ expect(onSearchChange).toHaveBeenCalledWith("alph");
68
+ } finally {
69
+ jest.useRealTimers();
70
+ }
71
+ });
72
+
73
+ test("the filter icon opens the faceted panel; applying reports the filters and keeps search open", () => {
74
+ const onApplyFilters = jest.fn();
75
+ render(wrap(<ContentBrowser {...base} onSearchChange={() => {}} onClearSearch={() => {}} onApplyFilters={onApplyFilters} />));
76
+
77
+ fireEvent.click(screen.getByTestId("filter-open"));
78
+ // The advanced panel replaces the entry list, showing the enum facet's values.
79
+ expect(screen.getByTestId("advanced-search")).toBeInTheDocument();
80
+ expect(screen.getByText("published")).toBeInTheDocument();
81
+
82
+ // Choose an enum value, then apply.
83
+ fireEvent.click(screen.getByText("published"));
84
+ fireEvent.click(screen.getByTestId("facet-apply"));
85
+
86
+ expect(onApplyFilters).toHaveBeenCalledTimes(1);
87
+ expect(onApplyFilters).toHaveBeenCalledWith([{ field: "status", op: "in", values: ["published"] }]);
88
+ // Panel closes back to the list, but the search input stays open.
89
+ expect(screen.queryByTestId("advanced-search")).not.toBeInTheDocument();
90
+ expect(screen.getByTestId("search-input")).toBeInTheDocument();
91
+ });
92
+
93
+ test("each facet renders the control its display component dictates", () => {
94
+ render(wrap(<ContentBrowser {...base} onSearchChange={() => {}} onClearSearch={() => {}} onApplyFilters={() => {}} />));
95
+ fireEvent.click(screen.getByTestId("filter-open"));
96
+
97
+ // component "select" → enum chips.
98
+ expect(screen.getByText("draft")).toBeInTheDocument();
99
+ expect(screen.getByText("published")).toBeInTheDocument();
100
+ // component "input" (plain string) → a free-text field, not chips.
101
+ expect(screen.getByTestId("facet-title")).toBeInTheDocument();
102
+ // component "date" → a From/To range.
103
+ expect(screen.getByTestId("facet-publishedAt-from")).toBeInTheDocument();
104
+ expect(screen.getByTestId("facet-publishedAt-to")).toBeInTheDocument();
105
+ });
106
+
107
+ test("a date facet applies a gte/lte range from its From/To inputs", () => {
108
+ const onApplyFilters = jest.fn();
109
+ render(wrap(<ContentBrowser {...base} onSearchChange={() => {}} onClearSearch={() => {}} onApplyFilters={onApplyFilters} />));
110
+ fireEvent.click(screen.getByTestId("filter-open"));
111
+
112
+ // The two DateControls render as native date inputs on web.
113
+ const inputs = screen.getAllByTestId("date-control");
114
+ fireEvent.change(inputs[0], { target: { value: "2026-01-01" } }); // From
115
+ fireEvent.change(inputs[1], { target: { value: "2026-12-31" } }); // To
116
+ fireEvent.click(screen.getByTestId("facet-apply"));
117
+
118
+ expect(onApplyFilters).toHaveBeenCalledWith([
119
+ { field: "publishedAt", op: "gte", value: "2026-01-01" },
120
+ { field: "publishedAt", op: "lte", value: "2026-12-31" },
121
+ ]);
122
+ });
123
+
124
+ test("the panel shows an optimistic match count from onCountMatches", async () => {
125
+ const onCountMatches = jest.fn(async () => 7);
126
+ render(wrap(<ContentBrowser {...base} onSearchChange={() => {}} onClearSearch={() => {}} onCountMatches={onCountMatches} />));
127
+
128
+ fireEvent.click(screen.getByTestId("filter-open"));
129
+ fireEvent.click(screen.getByText("draft"));
130
+
131
+ await waitFor(() => expect(onCountMatches).toHaveBeenCalled());
132
+ // The most recent call reflects the chosen draft filter.
133
+ expect(onCountMatches).toHaveBeenLastCalledWith({ query: "", filters: [{ field: "status", op: "in", values: ["draft"] }] });
134
+ await waitFor(() => expect(screen.getByTestId("facet-apply")).toHaveTextContent("Show 7 results"));
135
+ });