@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.
- package/README.md +5 -2
- package/css/tokens.css +1 -0
- package/package.json +1 -1
- package/src/__tests__/BranchMenu.test.tsx +38 -0
- package/src/__tests__/ContentBrowser.changes.test.tsx +23 -14
- package/src/__tests__/ContentBrowser.contentslot.test.tsx +10 -6
- package/src/__tests__/ContentBrowser.expand.test.tsx +170 -0
- package/src/__tests__/ContentBrowser.forms.test.tsx +25 -20
- package/src/__tests__/ContentBrowser.listrow.test.tsx +57 -0
- package/src/__tests__/ContentBrowser.master.test.tsx +147 -0
- package/src/__tests__/ContentBrowser.media.test.tsx +2 -1
- package/src/__tests__/ContentBrowser.references.test.tsx +3 -3
- package/src/__tests__/ContentBrowser.reorder.test.tsx +2 -3
- package/src/__tests__/ContentBrowser.window.test.tsx +203 -0
- package/src/__tests__/MediaBrowser.test.tsx +15 -9
- package/src/__tests__/ProtectedBranchModal.test.tsx +14 -0
- package/src/__tests__/cssTokens.test.ts +1 -0
- package/src/__tests__/documentDrafts.test.ts +59 -0
- package/src/components/BranchMenu.tsx +14 -4
- package/src/components/ContentBrowser.tsx +584 -460
- package/src/components/Input.tsx +37 -5
- package/src/components/MediaField.tsx +2 -2
- package/src/components/Notifications.tsx +10 -1
- package/src/components/ProjectMenu.tsx +100 -26
- package/src/components/ProtectedBranchModal.tsx +19 -7
- package/src/components/Skeleton.tsx +12 -19
- package/src/components/WorkspaceMenu.tsx +163 -0
- package/src/components/documentDrafts.ts +19 -4
- package/src/components/layout.tsx +33 -1
- package/src/icons.ts +5 -0
- package/src/index.ts +4 -1
- package/src/references.ts +1 -1
- 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` (
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gogitcms/design-system",
|
|
3
|
-
"version": "0.16.0-next.
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
288
|
-
expect(screen.queryByTestId("
|
|
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
|
|
310
|
-
|
|
311
|
-
expect(screen.
|
|
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
|
|
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
|
-
|
|
609
|
+
const { rerender } = render(
|
|
610
|
+
wrap(<ContentBrowser {...base} activeNavKey="" sections={withConflicts} entries={conflictedEntries} />),
|
|
611
|
+
);
|
|
605
612
|
|
|
606
|
-
//
|
|
613
|
+
// Sections: a red conflict pill on the collection.
|
|
607
614
|
expect(screen.getByTestId("nav-posts-conflicts")).toHaveTextContent("1");
|
|
608
|
-
// List
|
|
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
|
|
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
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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("
|
|
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
|
|
70
|
-
// sets under Media — not the whole area
|
|
71
|
-
// layout, so this asserts the mechanism: a fixed width rather than
|
|
72
|
-
|
|
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-
|
|
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
|
|
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
|
|
81
|
-
|
|
82
|
-
expect(screen.getByTestId("
|
|
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
|
|
99
|
-
//
|
|
100
|
-
// FormsBrowser.
|
|
101
|
-
|
|
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
|
-
|
|
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
|
|
114
|
-
//
|
|
115
|
-
//
|
|
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
|
-
|
|
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-
|
|
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
|
+
});
|