@gogitcms/design-system 0.16.0-next.12 → 0.16.0-next.14

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 (33) hide show
  1. package/README.md +5 -2
  2. package/css/tokens.css +1 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/BranchMenu.test.tsx +38 -0
  5. package/src/__tests__/ContentBrowser.changes.test.tsx +23 -14
  6. package/src/__tests__/ContentBrowser.contentslot.test.tsx +10 -6
  7. package/src/__tests__/ContentBrowser.expand.test.tsx +170 -0
  8. package/src/__tests__/ContentBrowser.forms.test.tsx +25 -20
  9. package/src/__tests__/ContentBrowser.listrow.test.tsx +57 -0
  10. package/src/__tests__/ContentBrowser.master.test.tsx +147 -0
  11. package/src/__tests__/ContentBrowser.media.test.tsx +2 -1
  12. package/src/__tests__/ContentBrowser.references.test.tsx +3 -3
  13. package/src/__tests__/ContentBrowser.reorder.test.tsx +2 -3
  14. package/src/__tests__/ContentBrowser.window.test.tsx +203 -0
  15. package/src/__tests__/MediaBrowser.test.tsx +15 -9
  16. package/src/__tests__/ProtectedBranchModal.test.tsx +14 -0
  17. package/src/__tests__/cssTokens.test.ts +1 -0
  18. package/src/__tests__/documentDrafts.test.ts +59 -0
  19. package/src/components/BranchMenu.tsx +14 -4
  20. package/src/components/ContentBrowser.tsx +584 -460
  21. package/src/components/Input.tsx +37 -5
  22. package/src/components/MediaField.tsx +2 -2
  23. package/src/components/Notifications.tsx +10 -1
  24. package/src/components/ProjectMenu.tsx +100 -26
  25. package/src/components/ProtectedBranchModal.tsx +19 -7
  26. package/src/components/Skeleton.tsx +12 -19
  27. package/src/components/WorkspaceMenu.tsx +163 -0
  28. package/src/components/documentDrafts.ts +19 -4
  29. package/src/components/layout.tsx +33 -1
  30. package/src/icons.ts +5 -0
  31. package/src/index.ts +4 -1
  32. package/src/references.ts +1 -1
  33. package/src/theme.ts +3 -1
package/README.md CHANGED
@@ -42,8 +42,11 @@ same components render:
42
42
  ## Responsive across all three targets
43
43
 
44
44
  `ContentBrowser` is the whole story: it calls `useResponsive()` and renders
45
- `AppShell` (sidebar → entries → editor panes) at desktop widths or `MobileScreen`
46
- (drill-down list → entries → entry) below the breakpoint.
45
+ `AppShell` (a master pane → editor panes) at desktop widths or `MobileScreen`
46
+ (drill-down list → entries → entry) below the breakpoint. The master pane lists
47
+ the sections at rest; selecting a collection pushes its entries in, headed by a
48
+ Back control and a title that switches between collections. A singleton has no
49
+ list, so it opens straight into a pane and the sections stay put.
47
50
 
48
51
  ```tsx
49
52
  import { ThemeProvider, ContentBrowser } from "@gogitcms/design-system";
package/css/tokens.css CHANGED
@@ -181,6 +181,7 @@
181
181
 
182
182
  /* Layout dims */
183
183
  --sidebar-w: 260px;
184
+ --master-w: 340px;
184
185
  --column-w: 300px;
185
186
  --topbar-h: 48px;
186
187
  --container: 1200px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gogitcms/design-system",
3
- "version": "0.16.0-next.12",
3
+ "version": "0.16.0-next.14",
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,38 @@
1
+ import React from "react";
2
+ import { render, screen, fireEvent, act } from "@testing-library/react";
3
+ import { ThemeProvider } from "../ThemeProvider";
4
+ import { BranchMenu } from "../components/BranchMenu";
5
+
6
+ const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
7
+
8
+ // The create form names the branch under the repository's prefix. The prefix is
9
+ // rendered as a fixed part of the field, not a placeholder or a hint, so the
10
+ // author cannot omit it — and the host receives the full name.
11
+ test("a branch prefix is shown read-only and applied to the created name", async () => {
12
+ const onCreate = jest.fn().mockResolvedValue(undefined);
13
+ render(wrap(<BranchMenu current="main" branches={[{ id: "1", name: "main" }]} prefix="cms/" onCreate={onCreate} />));
14
+
15
+ fireEvent.click(screen.getByTestId("branch-menu"));
16
+ fireEvent.click(screen.getByTestId("branch-create"));
17
+ expect(screen.getByTestId("branch-create-input-prefix")).toHaveTextContent("cms/");
18
+
19
+ fireEvent.change(screen.getByTestId("branch-create-input"), { target: { value: "spring" } });
20
+ await act(async () => {
21
+ fireEvent.click(screen.getByTestId("branch-create-submit"));
22
+ });
23
+ expect(onCreate).toHaveBeenCalledWith("cms/spring");
24
+ });
25
+
26
+ test("without a prefix the name is sent as typed", async () => {
27
+ const onCreate = jest.fn().mockResolvedValue(undefined);
28
+ render(wrap(<BranchMenu current="main" branches={[{ id: "1", name: "main" }]} onCreate={onCreate} />));
29
+
30
+ fireEvent.click(screen.getByTestId("branch-menu"));
31
+ fireEvent.click(screen.getByTestId("branch-create"));
32
+ expect(screen.queryByTestId("branch-create-input-prefix")).not.toBeInTheDocument();
33
+ fireEvent.change(screen.getByTestId("branch-create-input"), { target: { value: "feat/next" } });
34
+ await act(async () => {
35
+ fireEvent.click(screen.getByTestId("branch-create-submit"));
36
+ });
37
+ expect(onCreate).toHaveBeenCalledWith("feat/next");
38
+ });
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { render, screen, fireEvent, act } from "@testing-library/react";
2
+ import { render, screen, fireEvent, act, within } from "@testing-library/react";
3
3
  import { ThemeProvider } from "../ThemeProvider";
4
4
  import { ContentBrowser, type CmsEntry, type CmsNavSection, type ContentBrowserProps } from "../components/ContentBrowser";
5
5
  import { MEDIA_NAV_KEY } from "../media";
@@ -57,7 +57,8 @@ const base = {
57
57
  test("the changes surface lists changed entries with their status and diff stat", () => {
58
58
  render(wrap(<ContentBrowser {...base} />));
59
59
 
60
- expect(screen.getByTestId("pane-changes")).toBeInTheDocument();
60
+ // The changed entries are pushed into the master pane, like a collection's.
61
+ expect(within(screen.getByTestId("pane-master")).getByTestId("entries-list")).toBeInTheDocument();
61
62
  expect(screen.getByText("2 changed")).toBeInTheDocument();
62
63
 
63
64
  // Status letters and line counts come from the entry rows the editor already
@@ -233,13 +234,14 @@ test("the changes nav does not derive Media from the media api", () => {
233
234
  sets: [{ name: "images", label: "Images", count: 12 }],
234
235
  } as unknown as ContentBrowserProps["media"];
235
236
 
236
- const { rerender } = render(wrap(<ContentBrowser {...base} surface="edit" media={media} />));
237
+ // Nothing pushed into the master pane, so the sections are what it shows.
238
+ const { rerender } = render(wrap(<ContentBrowser {...base} activeNavKey="" surface="edit" media={media} />));
237
239
  // The editor derives it: all 12 files, regardless of any comparison.
238
240
  expect(screen.getByTestId(`nav-${MEDIA_NAV_KEY}`)).toBeInTheDocument();
239
241
 
240
242
  // Same media api, changes surface, no media section supplied → no row, because
241
243
  // nothing said any asset changed.
242
- rerender(wrap(<ContentBrowser {...base} surface="changes" media={media} />));
244
+ rerender(wrap(<ContentBrowser {...base} activeNavKey="" surface="changes" media={media} />));
243
245
  expect(screen.queryByTestId(`nav-${MEDIA_NAV_KEY}`)).not.toBeInTheDocument();
244
246
  });
245
247
 
@@ -253,7 +255,7 @@ test("the changes nav shows Media when the host reports changed assets", () => {
253
255
  { title: "", items: [{ key: MEDIA_NAV_KEY, label: "Media", icon: "image", count: 2 }] },
254
256
  ];
255
257
 
256
- render(wrap(<ContentBrowser {...base} surface="changes" sections={withMedia} media={media} />));
258
+ render(wrap(<ContentBrowser {...base} activeNavKey="" surface="changes" sections={withMedia} media={media} />));
257
259
 
258
260
  const row = screen.getByTestId(`nav-${MEDIA_NAV_KEY}`);
259
261
  expect(row).toBeInTheDocument();
@@ -284,8 +286,8 @@ test("the changes surface lists changed assets rather than opening the media bro
284
286
  ),
285
287
  );
286
288
 
287
- expect(screen.getByTestId("pane-changes")).toBeInTheDocument();
288
- expect(screen.queryByTestId("pane-media-sets")).not.toBeInTheDocument();
289
+ expect(within(screen.getByTestId("pane-master")).getByTestId("entries-list")).toBeInTheDocument();
290
+ expect(screen.queryByTestId("media-set-list")).not.toBeInTheDocument();
289
291
  expect(screen.getByText("public/images/logo.png")).toBeInTheDocument();
290
292
  expect(screen.getByText("2 changed")).toBeInTheDocument();
291
293
  });
@@ -306,9 +308,12 @@ test("the edit surface is unchanged when no surface is given", () => {
306
308
  ),
307
309
  );
308
310
 
309
- // No surface prop → the editor panes, not the changes ones.
310
- expect(screen.getByTestId("pane-entries")).toBeInTheDocument();
311
- expect(screen.queryByTestId("pane-changes")).not.toBeInTheDocument();
311
+ // No surface prop → the editor's list (counted as entries), not the
312
+ // changes one (counted as changed), and document columns beside it.
313
+ expect(screen.getByTestId("entries-list")).toBeInTheDocument();
314
+ expect(screen.getByText("2 entries")).toBeInTheDocument();
315
+ expect(screen.queryByText("2 changed")).not.toBeInTheDocument();
316
+ expect(screen.getByTestId("pane-detail")).toBeInTheDocument();
312
317
  expect(screen.queryByTestId("surface-segment")).not.toBeInTheDocument();
313
318
  });
314
319
 
@@ -593,7 +598,7 @@ test("override reveals an input and saves the entered value", () => {
593
598
  expect(onResolveConflict).toHaveBeenCalledWith(expect.objectContaining({ id: "c1" }), "override", "archived");
594
599
  });
595
600
 
596
- test("the sidebar shows a conflict count and the list highlights conflicted rows", () => {
601
+ test("the sections show a conflict count and the list highlights conflicted rows", () => {
597
602
  const withConflicts: CmsNavSection[] = [
598
603
  { title: "Changed content", items: [{ key: "posts", label: "Blog posts", icon: "newspaper", count: 2, conflicts: 1 }] },
599
604
  ];
@@ -601,11 +606,15 @@ test("the sidebar shows a conflict count and the list highlights conflicted rows
601
606
  { id: "a", path: "content/blog/git-content.md", title: "Editing content in Git", body: "", status: "M", conflicted: true },
602
607
  { id: "b", path: "content/blog/other.md", title: "Other", body: "", status: "M" },
603
608
  ];
604
- render(wrap(<ContentBrowser {...base} sections={withConflicts} entries={conflictedEntries} />));
609
+ const { rerender } = render(
610
+ wrap(<ContentBrowser {...base} activeNavKey="" sections={withConflicts} entries={conflictedEntries} />),
611
+ );
605
612
 
606
- // Sidebar: a red conflict pill on the collection.
613
+ // Sections: a red conflict pill on the collection.
607
614
  expect(screen.getByTestId("nav-posts-conflicts")).toHaveTextContent("1");
608
- // List: the conflicted row carries a Conflict badge; the clean one doesn't.
615
+ // List (pushed in once the collection is active): the conflicted row carries
616
+ // a Conflict badge; the clean one doesn't.
617
+ rerender(wrap(<ContentBrowser {...base} sections={withConflicts} entries={conflictedEntries} />));
609
618
  const badges = screen.getAllByText("Conflict");
610
619
  expect(badges.length).toBe(1);
611
620
  });
@@ -27,7 +27,7 @@ const base = {
27
27
  userInitials: "ED",
28
28
  } as const;
29
29
 
30
- test("contentSlot replaces the list/detail panes and keeps the sidebar", () => {
30
+ test("contentSlot takes the detail region and keeps the sections on screen", () => {
31
31
  render(
32
32
  wrap(
33
33
  <ContentBrowser
@@ -39,15 +39,19 @@ test("contentSlot replaces the list/detail panes and keeps the sidebar", () => {
39
39
  // The plugin pane renders the slot…
40
40
  expect(screen.getByTestId("pane-plugin")).toBeInTheDocument();
41
41
  expect(screen.getByTestId("plugin-screen")).toBeInTheDocument();
42
- // …the sidebar (with the plugin link) stays…
43
- expect(screen.getByText("Hello plugin")).toBeInTheDocument();
42
+ // …the master pane stays on the sections, with the plugin's row lit — a
43
+ // plugin screen has no list to push in…
44
+ expect(screen.getByTestId("nav-plugin:hello/hello")).toHaveAttribute("aria-current", "page");
44
45
  expect(screen.getByText("Posts")).toBeInTheDocument();
45
- // …and the entry list/detail panes are gone.
46
- expect(screen.queryByTestId("pane-entries")).not.toBeInTheDocument();
46
+ expect(screen.queryByTestId("master-back")).not.toBeInTheDocument();
47
+ // …and no entry list or document columns render.
48
+ expect(screen.queryByTestId("entries-list")).not.toBeInTheDocument();
49
+ expect(screen.queryByTestId("pane-detail")).not.toBeInTheDocument();
47
50
  });
48
51
 
49
52
  test("without contentSlot the normal panes render", () => {
50
53
  render(wrap(<ContentBrowser {...base} activeNavKey="posts" />));
51
54
  expect(screen.queryByTestId("pane-plugin")).not.toBeInTheDocument();
52
- expect(screen.getByTestId("pane-entries")).toBeInTheDocument();
55
+ expect(screen.getByTestId("entries-list")).toBeInTheDocument();
56
+ expect(screen.getByTestId("pane-detail")).toBeInTheDocument();
53
57
  });
@@ -0,0 +1,170 @@
1
+ import React from "react";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import { ThemeProvider } from "../ThemeProvider";
4
+ import {
5
+ ContentBrowser,
6
+ type CmsEntry,
7
+ type CmsNavSection,
8
+ type EntryField,
9
+ } from "../components/ContentBrowser";
10
+
11
+ // Force the desktop layout (jsdom reports width 0 → mobile otherwise).
12
+ jest.mock("../ThemeProvider", () => {
13
+ const actual = jest.requireActual("../ThemeProvider");
14
+ return { ...actual, useResponsive: () => ({ width: 1300, height: 900, isDesktop: true, isMobile: false }) };
15
+ });
16
+
17
+ const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
18
+
19
+ const sections: CmsNavSection[] = [
20
+ { title: "Content", items: [{ key: "posts", label: "Posts", icon: "newspaper" }] },
21
+ ];
22
+ const entries: CmsEntry[] = [
23
+ { id: "a", path: "posts/a.md", title: "Alpha", body: "" },
24
+ { id: "b", path: "posts/b.md", title: "Beta", 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
+ } as const;
35
+
36
+ // Shift-click is the "open in a new column" chord, so this leaves two columns.
37
+ const openSecondColumn = () => fireEvent.click(screen.getByText("Beta"), { shiftKey: true });
38
+
39
+ describe("expanding the master pane", () => {
40
+ it("takes the whole screen and gives the layout back", () => {
41
+ render(wrap(<ContentBrowser {...base} />));
42
+ expect(screen.getByTestId("pane-master")).toBeInTheDocument();
43
+ expect(screen.getByTestId("pane-detail")).toBeInTheDocument();
44
+
45
+ fireEvent.click(screen.getByTestId("master-expand"));
46
+ expect(screen.getByTestId("pane-master")).toBeInTheDocument();
47
+ expect(screen.queryByTestId("pane-detail")).not.toBeInTheDocument();
48
+ // Nothing left to resize against once one pane holds the screen.
49
+ expect(screen.queryByTestId("resize-master")).not.toBeInTheDocument();
50
+
51
+ // The same control, now pointing the other way.
52
+ const control = screen.getByTestId("master-expand");
53
+ expect(control).toHaveAttribute("aria-label", "Collapse pane");
54
+ fireEvent.click(control);
55
+ expect(screen.getByTestId("pane-detail")).toBeInTheDocument();
56
+ expect(screen.getByTestId("resize-master")).toBeInTheDocument();
57
+ expect(screen.getByTestId("master-expand")).toHaveAttribute("aria-label", "Expand pane");
58
+ });
59
+
60
+ it("is offered on the sections too, and stays expanded while a collection is pushed in", () => {
61
+ const { rerender } = render(wrap(<ContentBrowser {...base} activeNavKey="" entriesEmpty="Choose a collection" onSelectEntry={() => {}} />));
62
+ expect(screen.getByTestId("nav-posts")).toBeInTheDocument();
63
+ fireEvent.click(screen.getByTestId("master-expand"));
64
+ expect(screen.queryByTestId("pane-detail")).not.toBeInTheDocument();
65
+
66
+ // The host answers the row press by making the collection active. The
67
+ // pane the author enlarged is the one the list arrives in, so it holds.
68
+ rerender(wrap(<ContentBrowser {...base} entriesEmpty="Choose a collection" onSelectEntry={() => {}} />));
69
+ expect(screen.getByTestId("entries-list")).toBeInTheDocument();
70
+ expect(screen.queryByTestId("pane-detail")).not.toBeInTheDocument();
71
+ expect(screen.getByTestId("master-expand")).toHaveAttribute("aria-label", "Collapse pane");
72
+ });
73
+
74
+ it("collapses when a document is opened from it, since the column it opens in was hidden", () => {
75
+ render(wrap(<ContentBrowser {...base} />));
76
+ fireEvent.click(screen.getByTestId("master-expand"));
77
+ expect(screen.queryByTestId("pane-detail")).not.toBeInTheDocument();
78
+
79
+ fireEvent.click(screen.getByText("Beta"));
80
+ expect(screen.getByTestId("pane-detail")).toBeInTheDocument();
81
+ expect(screen.getByTestId("pane-master")).toBeInTheDocument();
82
+ expect(screen.getByTestId("column-b")).toBeInTheDocument();
83
+ });
84
+
85
+ it("collapses when a direct item is selected, since it opens in the hidden region", () => {
86
+ const withSingleton: CmsNavSection[] = [
87
+ ...sections,
88
+ { title: "Configure", items: [{ key: "settings", label: "Settings", icon: "settings", direct: true }] },
89
+ ];
90
+ const { rerender } = render(
91
+ wrap(<ContentBrowser {...base} sections={withSingleton} activeNavKey="" entriesEmpty="Choose a collection" onSelectEntry={() => {}} />),
92
+ );
93
+ fireEvent.click(screen.getByTestId("master-expand"));
94
+ expect(screen.queryByTestId("pane-detail")).not.toBeInTheDocument();
95
+
96
+ // The host answers the Settings row by making the singleton active.
97
+ rerender(
98
+ wrap(<ContentBrowser {...base} sections={withSingleton} activeNavKey="settings" entriesEmpty="Choose a collection" onSelectEntry={() => {}} />),
99
+ );
100
+ expect(screen.getByTestId("pane-detail")).toBeInTheDocument();
101
+ expect(screen.getByTestId("nav-settings")).toHaveAttribute("aria-current", "page");
102
+ });
103
+
104
+ it("collapses when a create starts, since the form opens in the hidden region", () => {
105
+ const createFields: EntryField[] = [{ name: "title", type: "string", label: "Title", value: "" }];
106
+ const { rerender } = render(wrap(<ContentBrowser {...base} canCreate onStartCreate={() => {}} />));
107
+ fireEvent.click(screen.getByTestId("master-expand"));
108
+ expect(screen.queryByTestId("pane-detail")).not.toBeInTheDocument();
109
+
110
+ rerender(
111
+ wrap(
112
+ <ContentBrowser
113
+ {...base}
114
+ canCreate
115
+ onStartCreate={() => {}}
116
+ creating
117
+ createFields={createFields}
118
+ onSubmitCreate={() => {}}
119
+ />
120
+ )
121
+ );
122
+ expect(screen.getByTestId("column-create")).toBeInTheDocument();
123
+ expect(screen.getByTestId("pane-detail")).toBeInTheDocument();
124
+ });
125
+ });
126
+
127
+ describe("expanding a document column", () => {
128
+ it("hides the master pane and the sibling columns, then restores them", () => {
129
+ render(wrap(<ContentBrowser {...base} />));
130
+ openSecondColumn();
131
+ expect(screen.getByTestId("column-a")).toBeInTheDocument();
132
+ expect(screen.getByTestId("column-b")).toBeInTheDocument();
133
+
134
+ // The first column's control; the second column's disappears with it.
135
+ fireEvent.click(screen.getAllByTestId("column-expand")[0]);
136
+ expect(screen.getByTestId("column-a")).toBeInTheDocument();
137
+ expect(screen.queryByTestId("column-b")).not.toBeInTheDocument();
138
+ expect(screen.queryByTestId("pane-master")).not.toBeInTheDocument();
139
+ expect(screen.queryByTestId("resize-master")).not.toBeInTheDocument();
140
+
141
+ const control = screen.getByTestId("column-expand");
142
+ expect(control).toHaveAttribute("aria-label", "Collapse column");
143
+ fireEvent.click(control);
144
+ // The hidden columns were never closed — they come back as they were.
145
+ expect(screen.getByTestId("column-b")).toBeInTheDocument();
146
+ expect(screen.getByTestId("pane-master")).toBeInTheDocument();
147
+ expect(screen.getByTestId("resize-master")).toBeInTheDocument();
148
+ });
149
+
150
+ it("has no reorder grip while it is alone on screen", () => {
151
+ render(wrap(<ContentBrowser {...base} />));
152
+ openSecondColumn();
153
+ expect(screen.getByTestId("col-grip-a")).toBeInTheDocument();
154
+
155
+ fireEvent.click(screen.getAllByTestId("column-expand")[0]);
156
+ expect(screen.queryByTestId("col-grip-a")).not.toBeInTheDocument();
157
+ });
158
+
159
+ it("restores the layout when the expanded column is closed", () => {
160
+ render(wrap(<ContentBrowser {...base} />));
161
+ openSecondColumn();
162
+ fireEvent.click(screen.getAllByTestId("column-expand")[0]);
163
+
164
+ fireEvent.click(screen.getByTestId("column-close"));
165
+ expect(screen.getByTestId("pane-master")).toBeInTheDocument();
166
+ expect(screen.getByTestId("entries-list")).toBeInTheDocument();
167
+ expect(screen.getByTestId("column-b")).toBeInTheDocument();
168
+ expect(screen.queryByTestId("column-a")).not.toBeInTheDocument();
169
+ });
170
+ });
@@ -66,20 +66,23 @@ function renderForms(activeNavKey: string, onSelectNav = jest.fn()) {
66
66
  return onSelectNav;
67
67
  }
68
68
 
69
- // The forms list is a content column, like the documents of a collection or the
70
- // sets under Media — not the whole area after the sidebar. jsdom computes no
71
- // layout, so this asserts the mechanism: a fixed width rather than a flex.
72
- test("the forms list sits in a content column, not the full content area", () => {
69
+ // The forms list is pushed into the master pane, like the documents of a
70
+ // collection or the sets under Media — not the whole area beside it. jsdom
71
+ // computes no layout, so this asserts the mechanism: a fixed width rather than
72
+ // a flex.
73
+ test("the forms list sits in the master pane, not the full content area", () => {
73
74
  renderForms(FORMS_NAV_KEY);
74
75
 
75
- const pane = screen.getByTestId("pane-forms");
76
+ const pane = screen.getByTestId("pane-master");
76
77
  expect(pane).toHaveStyle({ width: "340px" });
77
- // A flexing pane is the bug: it would fill everything after the sidebar.
78
+ // A flexing pane is the bug: it would fill everything.
78
79
  expect(pane).not.toHaveStyle({ flexGrow: 1 });
79
80
 
80
- // Headed like every other content pane, and with the seam that resizes it.
81
- expect(screen.getByText("Forms")).toBeInTheDocument();
82
- expect(screen.getByTestId("resize-forms")).toBeInTheDocument();
81
+ // Headed like every other pushed list — Back, the title — with the seam
82
+ // that resizes it.
83
+ expect(screen.getByTestId("master-back")).toBeInTheDocument();
84
+ expect(within(pane).getByText("Forms")).toBeInTheDocument();
85
+ expect(screen.getByTestId("resize-master")).toBeInTheDocument();
83
86
 
84
87
  // And the area beside it says what to do, the way Media and Changes do.
85
88
  expect(screen.getByTestId("forms-empty")).toBeInTheDocument();
@@ -95,10 +98,11 @@ test("the column lists the forms and opens one through the nav key", () => {
95
98
  expect(onSelectNav).toHaveBeenCalledWith(formsNavKey("contact"));
96
99
  });
97
100
 
98
- // The other half of the rule: submissions are two levels deeper than a content
99
- // column can express, so opening a form does hand the whole area to
100
- // FormsBrowser. Pinned so the fix above is not later applied to both states.
101
- test("an open form takes the whole content area", async () => {
101
+ // The other half of the rule: submissions are two levels deeper than the
102
+ // master pane can express, so opening a form hands the whole detail region to
103
+ // FormsBrowser. The forms list stays in the master pane so another form is a
104
+ // click away. Pinned so the fix above is not later applied to both states.
105
+ test("an open form takes the whole detail region, the list still beside it", async () => {
102
106
  renderForms(formsNavKey("contact"));
103
107
  // Let the submissions load settle before asserting, so the empty inbox — not
104
108
  // a pending fetch — is what is on screen.
@@ -107,12 +111,14 @@ test("an open form takes the whole content area", async () => {
107
111
  const pane = screen.getByTestId("pane-forms");
108
112
  expect(pane).not.toHaveStyle({ width: "340px" });
109
113
  expect(screen.queryByTestId("forms-empty")).not.toBeInTheDocument();
110
- expect(screen.queryByTestId("resize-forms")).not.toBeInTheDocument();
114
+ // The list of forms is still pushed into the master pane, headed as Forms.
115
+ expect(within(screen.getByTestId("pane-master")).getByText("Forms")).toBeInTheDocument();
116
+ expect(screen.getByTestId("form-row-newsletter")).toBeInTheDocument();
111
117
  });
112
118
 
113
- // Media, Forms and Changes head their content pane with PaneTitle; a collection
114
- // heads the same pane with its own header. They have to be the same heading, or
115
- // the title moves and changes size as you switch between them.
119
+ // Media, Forms, Changes and a collection all head the master pane with the
120
+ // same title. They have to be the same heading, or the title moves and changes
121
+ // size as you switch between them.
116
122
  test("the forms heading matches a collection's, not a smaller indented one", () => {
117
123
  const { unmount } = render(
118
124
  wrap(
@@ -127,8 +133,7 @@ test("the forms heading matches a collection's, not a smaller indented one", ()
127
133
  />,
128
134
  ),
129
135
  );
130
- // Scoped to the pane: "Forms" is also the nav item that got us here.
131
- const formsHeading = within(screen.getByTestId("pane-forms")).getByText("Forms");
136
+ const formsHeading = within(screen.getByTestId("pane-master")).getByText("Forms");
132
137
  // The heading sits directly in Pane's header row — the row with the pane's
133
138
  // height and the rule under it. A padded wrapper of its own in between is
134
139
  // what used to push this title a step right of every collection's.
@@ -148,7 +153,7 @@ test("the forms heading matches a collection's, not a smaller indented one", ()
148
153
  />,
149
154
  ),
150
155
  );
151
- const collectionHeading = within(screen.getByTestId("pane-entries")).getByText("Posts");
156
+ const collectionHeading = within(screen.getByTestId("pane-master")).getByText("Posts");
152
157
  const collectionStyle = window.getComputedStyle(collectionHeading);
153
158
 
154
159
  expect(formsStyle.fontSize).toBe(collectionStyle.fontSize);
@@ -0,0 +1,57 @@
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
+ // 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 base = {
19
+ workspace: { name: "acme/site", initials: "AC", branch: "main", changed: 0 },
20
+ sections,
21
+ activeNavKey: "posts",
22
+ onSelectNav: () => {},
23
+ userInitials: "ED",
24
+ } as const;
25
+
26
+ const daysAgo = (n: number) => new Date(Date.now() - n * 24 * 60 * 60 * 1000).toISOString();
27
+
28
+ test("a list row reads as its title and how long ago it changed", () => {
29
+ const entries: CmsEntry[] = [
30
+ { id: "a", path: "posts/a.md", title: "Alpha", body: "", updatedAt: daysAgo(3) },
31
+ ];
32
+ render(wrap(<ContentBrowser {...base} entries={entries} />));
33
+
34
+ // More than once: the list row, plus the column it auto-opened into.
35
+ expect(screen.getAllByText("Alpha").length).toBeGreaterThan(0);
36
+ expect(screen.getByTestId("entry-subtitle")).toHaveTextContent("Updated 3d ago");
37
+ // The path was what this line used to carry.
38
+ expect(screen.queryByText("posts/a.md")).not.toBeInTheDocument();
39
+ });
40
+
41
+ test("falls back to the path when the host tracks no timestamp", () => {
42
+ // The changes surface builds its rows from a diff, where the file is the
43
+ // subject and there is no document timestamp to show.
44
+ const entries: CmsEntry[] = [{ id: "a", path: "posts/a.md", title: "Alpha", body: "" }];
45
+ render(wrap(<ContentBrowser {...base} entries={entries} />));
46
+
47
+ expect(screen.getByTestId("entry-subtitle")).toHaveTextContent("posts/a.md");
48
+ });
49
+
50
+ test("falls back to the path when the timestamp doesn't parse", () => {
51
+ const entries: CmsEntry[] = [
52
+ { id: "a", path: "posts/a.md", title: "Alpha", body: "", updatedAt: "not a date" },
53
+ ];
54
+ render(wrap(<ContentBrowser {...base} entries={entries} />));
55
+
56
+ expect(screen.getByTestId("entry-subtitle")).toHaveTextContent("posts/a.md");
57
+ });