@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,185 @@
1
+ // A select has to show what the document actually holds.
2
+ //
3
+ // Three ways it didn't. A value the enum no longer lists matched no <option>, so
4
+ // the browser rendered the box blank — indistinguishable from unset, and the
5
+ // next save wrote that blankness back. A select over an integer/float field read
6
+ // the shared Y.Text while the server seeds numbers into the "reg" map, so it
7
+ // found nothing there ever. And every collab-bound select read empty until the
8
+ // room finished syncing, showing a saved document as an unset dropdown for as
9
+ // long as the handshake took.
10
+
11
+ import React from "react";
12
+ import { act, render, screen } from "@testing-library/react";
13
+ import { ThemeProvider } from "../ThemeProvider";
14
+ import {
15
+ ContentBrowser,
16
+ type CmsEntry,
17
+ type CmsNavSection,
18
+ type CollabApi,
19
+ type CollabRegister,
20
+ type CollabText,
21
+ type EntryField,
22
+ type OpenTab,
23
+ } from "../components/ContentBrowser";
24
+
25
+ jest.mock("../ThemeProvider", () => {
26
+ const actual = jest.requireActual("../ThemeProvider");
27
+ return { ...actual, useResponsive: () => ({ width: 1300, height: 900, isDesktop: true, isMobile: false }) };
28
+ });
29
+
30
+ const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
31
+
32
+ /**
33
+ * A stand-in for @gogitcms/realtime's DocCollab, modeling the two things the
34
+ * real one does that matter here: text and register are separate stores (the
35
+ * server seeds strings into one and scalars into the other), and the doc reads
36
+ * empty until `sync()` — no field value has arrived before then.
37
+ */
38
+ function fakeCollab() {
39
+ const texts = new Map<string, string>();
40
+ const regs = new Map<string, unknown>();
41
+ const listeners = new Map<string, Set<() => void>>();
42
+ const syncListeners = new Set<() => void>();
43
+ let synced = false;
44
+ const noPeers: never[] = [];
45
+
46
+ const notify = (path: string) => listeners.get(path)?.forEach((l) => l());
47
+ const listen = (path: string, cb: () => void) => {
48
+ let set = listeners.get(path);
49
+ if (!set) listeners.set(path, (set = new Set()));
50
+ set.add(cb);
51
+ return () => void set!.delete(cb);
52
+ };
53
+
54
+ // Both caches memoize per path: useSyncExternalStore re-renders until an
55
+ // unchanged store gives back a stable snapshot.
56
+ const textCache = new Map<string, CollabText>();
57
+ const text = (path: string): CollabText => {
58
+ let binding = textCache.get(path);
59
+ if (!binding) {
60
+ textCache.set(path, (binding = {
61
+ get: () => texts.get(path) ?? "",
62
+ set: (next) => { texts.set(path, next); notify(path); },
63
+ peers: () => noPeers,
64
+ subscribe: (cb) => listen(path, cb),
65
+ }));
66
+ }
67
+ return binding;
68
+ };
69
+ const regCache = new Map<string, CollabRegister>();
70
+ const register = (path: string): CollabRegister => {
71
+ let binding = regCache.get(path);
72
+ if (!binding) {
73
+ regCache.set(path, (binding = {
74
+ get: () => regs.get(path),
75
+ set: (next) => { regs.set(path, next); notify(path); },
76
+ subscribe: (cb) => listen(path, cb),
77
+ }));
78
+ }
79
+ return binding;
80
+ };
81
+
82
+ const collab: CollabApi = {
83
+ text,
84
+ register,
85
+ setFocus: () => {},
86
+ peersAt: () => noPeers,
87
+ peersUnder: () => noPeers,
88
+ participants: () => noPeers,
89
+ awarenessVersion: () => 0,
90
+ subscribeAwareness: () => () => {},
91
+ synced: () => synced,
92
+ subscribeSynced: (cb) => {
93
+ syncListeners.add(cb);
94
+ return () => void syncListeners.delete(cb);
95
+ },
96
+ ydoc: {},
97
+ awareness: {},
98
+ };
99
+
100
+ return {
101
+ collab,
102
+ /** The server's seeded state arriving: strings as Y.Text, scalars as registers. */
103
+ sync(seed: { text?: Record<string, string>; reg?: Record<string, unknown> } = {}) {
104
+ act(() => {
105
+ synced = true;
106
+ for (const [p, v] of Object.entries(seed.text ?? {})) { texts.set(p, v); notify(p); }
107
+ for (const [p, v] of Object.entries(seed.reg ?? {})) { regs.set(p, v); notify(p); }
108
+ syncListeners.forEach((cb) => cb());
109
+ });
110
+ },
111
+ };
112
+ }
113
+
114
+ const sections: CmsNavSection[] = [
115
+ { title: "Content", items: [{ key: "posts", label: "Posts", icon: "newspaper" }] },
116
+ ];
117
+
118
+ const base = {
119
+ workspace: { name: "acme/site", initials: "AC", branch: "main", changed: 0 },
120
+ sections,
121
+ activeNavKey: "posts",
122
+ onSelectNav: () => {},
123
+ entries: [{ id: "a", path: "posts/a.md", title: "Alpha", body: "", fields: [] } as CmsEntry],
124
+ userInitials: "ED",
125
+ } as const;
126
+
127
+ function renderDoc(fields: EntryField[], collab?: CollabApi) {
128
+ const entry: CmsEntry = { id: "a", path: "posts/a.md", title: "Alpha", body: "", collab, fields };
129
+ return render(
130
+ wrap(
131
+ <ContentBrowser
132
+ {...base}
133
+ openTabs={[{ id: "a", collection: "posts", entry } as OpenTab]}
134
+ activeTabId="a"
135
+ onSaveEntry={jest.fn()}
136
+ />
137
+ )
138
+ );
139
+ }
140
+
141
+ const control = () => screen.getByTestId("select-control") as HTMLSelectElement;
142
+
143
+ test("a stored value the enum no longer lists still shows, marked", () => {
144
+ renderDoc([
145
+ { name: "status", label: "Status", type: "string", component: "select", enumValues: ["draft", "shipped"], value: "Shipped" },
146
+ ]);
147
+ expect(control().value).toBe("Shipped");
148
+ expect(screen.getByText("Shipped (not in list)")).toBeInTheDocument();
149
+ });
150
+
151
+ test("a numeric select reads the register the server seeds scalars into", () => {
152
+ const { collab, sync } = fakeCollab();
153
+ renderDoc(
154
+ [{ name: "year", label: "Year", type: "integer", component: "select", enumValues: ["2024", "2025"], value: 2025 }],
155
+ collab
156
+ );
157
+ sync({ reg: { year: 2025 } });
158
+ expect(control().value).toBe("2025");
159
+ });
160
+
161
+ test("a collab select shows the document's value until the room has synced", () => {
162
+ const { collab, sync } = fakeCollab();
163
+ renderDoc(
164
+ [{ name: "status", label: "Status", type: "string", component: "select", enumValues: ["draft", "shipped"], value: "shipped" }],
165
+ collab
166
+ );
167
+ // Nothing has arrived over the wire yet — the host's own value stands in.
168
+ expect(control().value).toBe("shipped");
169
+ sync({ text: { status: "shipped" } });
170
+ expect(control().value).toBe("shipped");
171
+ });
172
+
173
+ test("a value already in the shared doc wins over the host's, synced or not", () => {
174
+ const { collab, sync } = fakeCollab();
175
+ renderDoc(
176
+ [{ name: "status", label: "Status", type: "string", component: "select", enumValues: ["draft", "shipped"], value: "shipped" }],
177
+ collab
178
+ );
179
+ // A peer's edit reaching the field, and a peer clearing it — neither is
180
+ // undone by the stale local value.
181
+ sync({ text: { status: "draft" } });
182
+ expect(control().value).toBe("draft");
183
+ act(() => collab.text("status").set(""));
184
+ expect(control().value).toBe("");
185
+ });
@@ -0,0 +1,132 @@
1
+ // A document does not always arrive in one piece.
2
+ //
3
+ // The host loads a document and its collection's field definitions with separate
4
+ // queries, so on a cold load either half can land first, and a refetch replaces
5
+ // both later on. The editor seeded itself once, keyed on the document id, and
6
+ // never looked again — so a form that mounted before its values existed showed
7
+ // every field empty for as long as it stayed open, with the values sitting right
8
+ // there in the entry it was rendering from.
9
+ //
10
+ // Re-reading the document is only half of it. The other half is that an author's
11
+ // work — an edit in flight, a stored draft — must survive the document moving
12
+ // underneath it.
13
+
14
+ import React from "react";
15
+ import { fireEvent, render, screen } from "@testing-library/react";
16
+ import { ThemeProvider } from "../ThemeProvider";
17
+ import {
18
+ ContentBrowser,
19
+ type CmsEntry,
20
+ type CmsNavSection,
21
+ type EntryField,
22
+ type OpenTab,
23
+ } from "../components/ContentBrowser";
24
+ import { refreshDrafts, writeDraft } from "../components/documentDrafts";
25
+
26
+ jest.mock("../ThemeProvider", () => {
27
+ const actual = jest.requireActual("../ThemeProvider");
28
+ return { ...actual, useResponsive: () => ({ width: 1300, height: 900, isDesktop: true, isMobile: false }) };
29
+ });
30
+
31
+ const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
32
+
33
+ const sections: CmsNavSection[] = [
34
+ { title: "Content", items: [{ key: "posts", label: "Posts", icon: "newspaper" }] },
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: [{ id: "a", path: "posts/a.md", title: "Alpha", body: "", fields: [] } as CmsEntry],
43
+ userInitials: "ED",
44
+ } as const;
45
+
46
+ beforeEach(() => {
47
+ localStorage.clear();
48
+ refreshDrafts();
49
+ });
50
+
51
+ const fieldsWith = (title: string, status: string): EntryField[] => [
52
+ { name: "title", label: "Title", type: "string", value: title },
53
+ { name: "status", label: "Status", type: "string", component: "select", enumValues: ["draft", "shipped"], value: status },
54
+ ];
55
+ // The same field definitions before the document's values have arrived.
56
+ const defsOnly: EntryField[] = fieldsWith("", "").map((f) => ({ ...f, value: undefined }));
57
+
58
+ const doc = (fields?: EntryField[]): CmsEntry => ({
59
+ id: "a",
60
+ path: "posts/a.md",
61
+ title: "Alpha",
62
+ body: "body text",
63
+ fields,
64
+ });
65
+
66
+ const ui = (entry?: CmsEntry) =>
67
+ wrap(
68
+ <ContentBrowser
69
+ {...base}
70
+ openTabs={[{ id: "a", collection: "posts", entry, loading: !entry } as OpenTab]}
71
+ activeTabId="a"
72
+ onSaveEntry={jest.fn()}
73
+ />
74
+ );
75
+
76
+ const status = () => (screen.getByTestId("select-control") as HTMLSelectElement).value;
77
+
78
+ describe("a document that arrives in stages", () => {
79
+ it("fills in values that land after the fields they belong to", () => {
80
+ const { rerender } = render(ui(doc(defsOnly)));
81
+ expect(status()).toBe("");
82
+
83
+ rerender(ui(doc(fieldsWith("Alpha", "shipped"))));
84
+ expect(screen.getByDisplayValue("Alpha")).toBeInTheDocument();
85
+ expect(status()).toBe("shipped");
86
+ });
87
+
88
+ it("fills in values when the collection schema is the half that was missing", () => {
89
+ // No fields at all: the editor falls back to its title/body form.
90
+ const { rerender } = render(ui(doc(undefined)));
91
+ expect(screen.queryByTestId("select-control")).not.toBeInTheDocument();
92
+
93
+ rerender(ui(doc(fieldsWith("Alpha", "shipped"))));
94
+ expect(screen.getByDisplayValue("Alpha")).toBeInTheDocument();
95
+ expect(status()).toBe("shipped");
96
+ });
97
+
98
+ it("shows a saved change made elsewhere while the document sits open untouched", () => {
99
+ const { rerender } = render(ui(doc(fieldsWith("Alpha", "draft"))));
100
+ rerender(ui(doc(fieldsWith("Alpha", "shipped"))));
101
+ expect(status()).toBe("shipped");
102
+ });
103
+ });
104
+
105
+ describe("what the author has in hand survives it", () => {
106
+ it("an edit in flight is not overwritten when the entry re-renders", () => {
107
+ const { rerender } = render(ui(doc(fieldsWith("Alpha", "draft"))));
108
+ fireEvent.change(screen.getByDisplayValue("Alpha"), { target: { value: "Beta" } });
109
+
110
+ rerender(ui(doc(fieldsWith("Alpha", "draft"))));
111
+ expect(screen.getByDisplayValue("Beta")).toBeInTheDocument();
112
+ });
113
+
114
+ it("an edit in flight survives the saved document moving underneath it", () => {
115
+ const { rerender } = render(ui(doc(fieldsWith("Alpha", "draft"))));
116
+ fireEvent.change(screen.getByDisplayValue("Alpha"), { target: { value: "Beta" } });
117
+
118
+ // Someone else's save arrives for this document. The author's unsaved work
119
+ // stays on screen — it is the only copy of it there is.
120
+ rerender(ui(doc(fieldsWith("Gamma", "draft"))));
121
+ expect(screen.getByDisplayValue("Beta")).toBeInTheDocument();
122
+ });
123
+
124
+ it("a stored draft is not thrown away by a late-arriving document", () => {
125
+ writeDraft("a", { title: "Drafted", status: "draft" });
126
+ refreshDrafts();
127
+
128
+ const { rerender } = render(ui(doc(defsOnly)));
129
+ rerender(ui(doc(fieldsWith("Alpha", "shipped"))));
130
+ expect(screen.getByDisplayValue("Drafted")).toBeInTheDocument();
131
+ });
132
+ });
@@ -0,0 +1,56 @@
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 UserMenuItem } 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
+
19
+ const base = {
20
+ workspace: { name: "acme/site", initials: "AC", branch: "main", changed: 0 },
21
+ sections,
22
+ activeNavKey: "posts",
23
+ onSelectNav: () => {},
24
+ entries,
25
+ userInitials: "ED",
26
+ } as const;
27
+
28
+ test("without userMenuItems the topbar keeps the sign-out icon and static avatar", () => {
29
+ render(wrap(<ContentBrowser {...base} onSignOut={() => {}} />));
30
+ expect(screen.getByLabelText("Sign out")).toBeInTheDocument();
31
+ expect(screen.queryByTestId("user-menu")).not.toBeInTheDocument();
32
+ });
33
+
34
+ test("userMenuItems turn the avatar into a dropdown with items and Sign out", () => {
35
+ const onSelect = jest.fn();
36
+ const onSignOut = jest.fn();
37
+ const items: UserMenuItem[] = [
38
+ { label: "Plugins", icon: "settings", onSelect, testID: "user-menu-plugins" },
39
+ ];
40
+ render(wrap(<ContentBrowser {...base} onSignOut={onSignOut} userMenuItems={items} />));
41
+
42
+ // The standalone sign-out icon is replaced by the menu.
43
+ expect(screen.queryByLabelText("Sign out")).not.toBeInTheDocument();
44
+
45
+ // Closed by default; opens on avatar press.
46
+ expect(screen.queryByTestId("user-menu-plugins")).not.toBeInTheDocument();
47
+ fireEvent.click(screen.getByTestId("user-menu"));
48
+ fireEvent.click(screen.getByTestId("user-menu-plugins"));
49
+ expect(onSelect).toHaveBeenCalledTimes(1);
50
+ // Selecting closes the menu.
51
+ expect(screen.queryByTestId("user-menu-plugins")).not.toBeInTheDocument();
52
+
53
+ fireEvent.click(screen.getByTestId("user-menu"));
54
+ fireEvent.click(screen.getByTestId("user-menu-sign-out"));
55
+ expect(onSignOut).toHaveBeenCalledTimes(1);
56
+ });