@gogitcms/design-system 0.16.0-next.13 → 0.16.0-next.15
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 +6 -4
- package/css/tokens.css +1 -0
- package/package.json +2 -2
- 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.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 +561 -445
- package/src/components/Input.tsx +37 -5
- package/src/components/Notifications.tsx +1 -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 +162 -0
- package/src/components/documentDrafts.ts +19 -4
- package/src/components/layout.tsx +33 -1
- package/src/history.ts +1 -1
- package/src/icons.ts +5 -0
- package/src/index.ts +4 -1
- package/src/theme.ts +3 -1
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen, fireEvent, within } 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
|
+
// Two collections and a singleton, which is what a real config looks like: the
|
|
15
|
+
// collections list, the singleton opens directly.
|
|
16
|
+
const sections: CmsNavSection[] = [
|
|
17
|
+
{
|
|
18
|
+
title: "Content",
|
|
19
|
+
items: [
|
|
20
|
+
{ key: "posts", label: "Posts", icon: "newspaper", count: 2 },
|
|
21
|
+
{ key: "pages", label: "Pages", icon: "file", count: 4 },
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
{ title: "Configure", items: [{ key: "settings", label: "Settings", icon: "settings", direct: true }] },
|
|
25
|
+
];
|
|
26
|
+
const entries: CmsEntry[] = [
|
|
27
|
+
{ id: "a", path: "posts/a.md", title: "Alpha", body: "" },
|
|
28
|
+
{ id: "b", path: "posts/b.md", title: "Beta", body: "" },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// A controlled, browsing host — the editor's own configuration.
|
|
32
|
+
const base = {
|
|
33
|
+
workspace: { name: "acme/site", initials: "AC", branch: "main", changed: 0 },
|
|
34
|
+
sections,
|
|
35
|
+
onSelectNav: () => {},
|
|
36
|
+
entries,
|
|
37
|
+
userInitials: "ED",
|
|
38
|
+
entriesEmpty: "Choose a collection",
|
|
39
|
+
selectedEntryId: null,
|
|
40
|
+
onSelectEntry: () => {},
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
describe("the master pane at rest", () => {
|
|
44
|
+
it("lists the sections and says what to do beside them", () => {
|
|
45
|
+
render(wrap(<ContentBrowser {...base} activeNavKey="" />));
|
|
46
|
+
const master = screen.getByTestId("pane-master");
|
|
47
|
+
expect(within(master).getByTestId("master-title")).toHaveTextContent("Content");
|
|
48
|
+
expect(screen.getByTestId("nav-posts")).toBeInTheDocument();
|
|
49
|
+
expect(screen.getByTestId("nav-pages")).toBeInTheDocument();
|
|
50
|
+
expect(screen.getByTestId("nav-settings")).toBeInTheDocument();
|
|
51
|
+
// Nothing is pushed in, so there is nothing to go back from.
|
|
52
|
+
expect(screen.queryByTestId("master-back")).not.toBeInTheDocument();
|
|
53
|
+
expect(screen.queryByTestId("entries-list")).not.toBeInTheDocument();
|
|
54
|
+
expect(screen.getByTestId("detail-empty")).toHaveTextContent("Choose a collection");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("pushes a collection in through the host", () => {
|
|
58
|
+
const onSelectNav = jest.fn();
|
|
59
|
+
render(wrap(<ContentBrowser {...base} activeNavKey="" onSelectNav={onSelectNav} />));
|
|
60
|
+
fireEvent.click(screen.getByTestId("nav-posts"));
|
|
61
|
+
expect(onSelectNav).toHaveBeenCalledWith("posts");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("a pushed collection", () => {
|
|
66
|
+
it("replaces the sections with the collection's entries, headed by Back and its name", () => {
|
|
67
|
+
render(wrap(<ContentBrowser {...base} activeNavKey="posts" />));
|
|
68
|
+
const master = screen.getByTestId("pane-master");
|
|
69
|
+
expect(within(master).getByText("Alpha")).toBeInTheDocument();
|
|
70
|
+
expect(within(master).getByText("Beta")).toBeInTheDocument();
|
|
71
|
+
expect(screen.queryByTestId("nav-posts")).not.toBeInTheDocument();
|
|
72
|
+
expect(screen.getByTestId("master-back")).toBeInTheDocument();
|
|
73
|
+
expect(screen.getByTestId("master-title")).toHaveTextContent("Posts");
|
|
74
|
+
// The region beside it prompts for a pick from the pushed list.
|
|
75
|
+
expect(screen.getByTestId("detail-empty")).toHaveTextContent("Select Posts");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("goes back to the sections by clearing the selection", () => {
|
|
79
|
+
const onSelectNav = jest.fn();
|
|
80
|
+
render(wrap(<ContentBrowser {...base} activeNavKey="posts" onSelectNav={onSelectNav} />));
|
|
81
|
+
fireEvent.click(screen.getByTestId("master-back"));
|
|
82
|
+
expect(onSelectNav).toHaveBeenCalledWith("");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("switches collections from its title, leaving out what has no list", () => {
|
|
86
|
+
const onSelectNav = jest.fn();
|
|
87
|
+
render(wrap(<ContentBrowser {...base} activeNavKey="posts" onSelectNav={onSelectNav} />));
|
|
88
|
+
expect(screen.queryByTestId("master-menu")).not.toBeInTheDocument();
|
|
89
|
+
|
|
90
|
+
fireEvent.click(screen.getByTestId("master-title"));
|
|
91
|
+
const menu = screen.getByTestId("master-menu");
|
|
92
|
+
expect(within(menu).getByTestId("master-option-posts")).toHaveAttribute("aria-current", "page");
|
|
93
|
+
expect(within(menu).getByTestId("master-option-pages")).toBeInTheDocument();
|
|
94
|
+
// A singleton opens directly; there is no list to switch to.
|
|
95
|
+
expect(within(menu).queryByTestId("master-option-settings")).not.toBeInTheDocument();
|
|
96
|
+
|
|
97
|
+
fireEvent.click(screen.getByTestId("master-option-pages"));
|
|
98
|
+
expect(onSelectNav).toHaveBeenCalledWith("pages");
|
|
99
|
+
expect(screen.queryByTestId("master-menu")).not.toBeInTheDocument();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("does not re-select the collection it is already on", () => {
|
|
103
|
+
const onSelectNav = jest.fn();
|
|
104
|
+
render(wrap(<ContentBrowser {...base} activeNavKey="posts" onSelectNav={onSelectNav} />));
|
|
105
|
+
fireEvent.click(screen.getByTestId("master-title"));
|
|
106
|
+
fireEvent.click(screen.getByTestId("master-option-posts"));
|
|
107
|
+
expect(onSelectNav).not.toHaveBeenCalled();
|
|
108
|
+
expect(screen.queryByTestId("master-menu")).not.toBeInTheDocument();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("has a plain title when there is only one list to offer", () => {
|
|
112
|
+
const one: CmsNavSection[] = [
|
|
113
|
+
{ title: "Content", items: [{ key: "posts", label: "Posts", icon: "newspaper" }] },
|
|
114
|
+
{ title: "Configure", items: [{ key: "settings", label: "Settings", icon: "settings", direct: true }] },
|
|
115
|
+
];
|
|
116
|
+
render(wrap(<ContentBrowser {...base} sections={one} activeNavKey="posts" />));
|
|
117
|
+
expect(screen.getByTestId("master-title")).toHaveTextContent("Posts");
|
|
118
|
+
fireEvent.click(screen.getByTestId("master-title"));
|
|
119
|
+
expect(screen.queryByTestId("master-menu")).not.toBeInTheDocument();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("a singleton", () => {
|
|
124
|
+
const doc: CmsEntry = { id: "s", path: "settings.yaml", title: "Settings", body: "" };
|
|
125
|
+
|
|
126
|
+
it("opens in the detail region and leaves the sections in place, its row lit", () => {
|
|
127
|
+
render(
|
|
128
|
+
wrap(
|
|
129
|
+
<ContentBrowser
|
|
130
|
+
{...base}
|
|
131
|
+
activeNavKey="settings"
|
|
132
|
+
entries={[doc]}
|
|
133
|
+
selectedEntryId="s"
|
|
134
|
+
openTabs={[{ id: "s", collection: "settings", entry: doc }]}
|
|
135
|
+
activeTabId="s"
|
|
136
|
+
/>,
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
// No list was pushed: the sections are still what the master shows.
|
|
140
|
+
expect(screen.getByTestId("nav-settings")).toHaveAttribute("aria-current", "page");
|
|
141
|
+
expect(screen.getByTestId("nav-posts")).toBeInTheDocument();
|
|
142
|
+
expect(screen.queryByTestId("master-back")).not.toBeInTheDocument();
|
|
143
|
+
expect(screen.queryByTestId("entries-list")).not.toBeInTheDocument();
|
|
144
|
+
// The document itself is open beside them.
|
|
145
|
+
expect(screen.getByTestId("column-s")).toBeInTheDocument();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -28,10 +28,9 @@ const base = {
|
|
|
28
28
|
userInitials: "ED",
|
|
29
29
|
} as const;
|
|
30
30
|
|
|
31
|
-
test("
|
|
31
|
+
test("the master pane's resize seam renders on desktop", () => {
|
|
32
32
|
render(wrap(<ContentBrowser {...base} />));
|
|
33
|
-
expect(screen.getByTestId("resize-
|
|
34
|
-
expect(screen.getByTestId("resize-list")).toBeInTheDocument();
|
|
33
|
+
expect(screen.getByTestId("resize-master")).toBeInTheDocument();
|
|
35
34
|
});
|
|
36
35
|
|
|
37
36
|
test("column reorder grips appear only once a second column is open", () => {
|
|
@@ -0,0 +1,203 @@
|
|
|
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
|
+
import { WorkspaceMenu, workspaceInitials } from "../components/WorkspaceMenu";
|
|
6
|
+
import { ProjectMenu } from "../components/ProjectMenu";
|
|
7
|
+
|
|
8
|
+
// Force the desktop layout (jsdom reports width 0 → mobile otherwise).
|
|
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
|
+
const entries: CmsEntry[] = [{ id: "a", path: "posts/a.md", title: "Alpha", body: "" }];
|
|
20
|
+
|
|
21
|
+
const base = {
|
|
22
|
+
workspace: { name: "Acme", initials: "AC", branch: "main", changed: 0 },
|
|
23
|
+
sections,
|
|
24
|
+
activeNavKey: "posts",
|
|
25
|
+
onSelectNav: () => {},
|
|
26
|
+
entries,
|
|
27
|
+
userInitials: "ED",
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
const workspaces = [
|
|
31
|
+
{ id: "w1", name: "Acme Inc", slug: "acme" },
|
|
32
|
+
{ id: "w2", name: "Side Project", slug: "side" },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// Two repositories, each with a project called "docs": the list is keyed by
|
|
36
|
+
// repository as well as name, or the two would be one row.
|
|
37
|
+
const projects = [
|
|
38
|
+
{ key: "r1:website", name: "website", label: "Marketing site", detail: "acme/platform · website" },
|
|
39
|
+
{ key: "r1:docs", name: "docs", label: "Documentation", detail: "acme/platform · docs" },
|
|
40
|
+
{ key: "r2:docs", name: "docs", label: "Handbook", detail: "acme/handbook · docs" },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
test("without a workspace menu the top bar keeps the static workspace tag", () => {
|
|
44
|
+
render(wrap(<ContentBrowser {...base} />));
|
|
45
|
+
expect(screen.getByText("Acme")).toBeInTheDocument();
|
|
46
|
+
expect(screen.queryByTestId("workspace-menu")).not.toBeInTheDocument();
|
|
47
|
+
// No window chrome either: the web and mobile apps never get a drag region.
|
|
48
|
+
expect(screen.getByTestId("top-bar")).not.toHaveAttribute("data-window-drag");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("the workspace menu lists every workspace and switches to the one chosen", () => {
|
|
52
|
+
const onSelect = jest.fn();
|
|
53
|
+
const onOverview = jest.fn();
|
|
54
|
+
render(
|
|
55
|
+
wrap(
|
|
56
|
+
<ContentBrowser
|
|
57
|
+
{...base}
|
|
58
|
+
workspaceMenu={{
|
|
59
|
+
workspaces,
|
|
60
|
+
current: "w1",
|
|
61
|
+
onSelect,
|
|
62
|
+
actions: [{ label: "Workspace overview", onSelect: onOverview, testID: "workspace-overview" }],
|
|
63
|
+
}}
|
|
64
|
+
/>,
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
// It names the current workspace, not the host's static tag.
|
|
68
|
+
expect(screen.getByTestId("workspace-name")).toHaveTextContent("Acme Inc");
|
|
69
|
+
expect(screen.queryByTestId("workspace-option-acme")).not.toBeInTheDocument();
|
|
70
|
+
|
|
71
|
+
fireEvent.click(screen.getByTestId("workspace-menu"));
|
|
72
|
+
expect(screen.getByTestId("workspace-option-acme")).toHaveTextContent("Acme Inc");
|
|
73
|
+
expect(screen.getByTestId("workspace-option-side")).toHaveTextContent("side");
|
|
74
|
+
|
|
75
|
+
// Choosing the one already shown just closes the menu.
|
|
76
|
+
fireEvent.click(screen.getByTestId("workspace-option-acme"));
|
|
77
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
78
|
+
expect(screen.queryByTestId("workspace-menu-list")).not.toBeInTheDocument();
|
|
79
|
+
|
|
80
|
+
fireEvent.click(screen.getByTestId("workspace-menu"));
|
|
81
|
+
fireEvent.click(screen.getByTestId("workspace-option-side"));
|
|
82
|
+
expect(onSelect).toHaveBeenCalledWith(workspaces[1]);
|
|
83
|
+
|
|
84
|
+
fireEvent.click(screen.getByTestId("workspace-menu"));
|
|
85
|
+
fireEvent.click(screen.getByTestId("workspace-overview"));
|
|
86
|
+
expect(onOverview).toHaveBeenCalledTimes(1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a workspace-wide project list tells same-named projects apart by key", () => {
|
|
90
|
+
const onSelect = jest.fn();
|
|
91
|
+
render(wrap(<ProjectMenu projects={projects} current="r2:docs" onSelect={onSelect} />));
|
|
92
|
+
// The badge names the current project by its label.
|
|
93
|
+
expect(screen.getByTestId("project-trigger")).toHaveTextContent("Handbook");
|
|
94
|
+
|
|
95
|
+
fireEvent.click(screen.getByTestId("project-trigger"));
|
|
96
|
+
expect(screen.getByTestId("project-option-r1:docs")).toHaveTextContent("acme/platform · docs");
|
|
97
|
+
expect(screen.getByTestId("project-option-r2:docs")).toHaveTextContent("acme/handbook · docs");
|
|
98
|
+
|
|
99
|
+
fireEvent.click(screen.getByTestId("project-option-r1:docs"));
|
|
100
|
+
expect(onSelect).toHaveBeenCalledWith(projects[1]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a project can be opened in a new window instead of switching this one", () => {
|
|
104
|
+
const onSelect = jest.fn();
|
|
105
|
+
const onOpenInNewWindow = jest.fn();
|
|
106
|
+
render(
|
|
107
|
+
wrap(
|
|
108
|
+
<ContentBrowser
|
|
109
|
+
{...base}
|
|
110
|
+
projects={projects}
|
|
111
|
+
currentProject="r1:website"
|
|
112
|
+
onSelectProject={onSelect}
|
|
113
|
+
onOpenProjectInNewWindow={onOpenInNewWindow}
|
|
114
|
+
/>,
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
fireEvent.click(screen.getByTestId("project-trigger"));
|
|
118
|
+
fireEvent.click(screen.getByTestId("project-new-window-r2:docs"));
|
|
119
|
+
expect(onOpenInNewWindow).toHaveBeenCalledWith(projects[2]);
|
|
120
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
121
|
+
// Opening elsewhere closes the menu here.
|
|
122
|
+
expect(screen.queryByTestId("project-new-window-r2:docs")).not.toBeInTheDocument();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a manifest-name list renders exactly as before: no new-window control, name under the label", () => {
|
|
126
|
+
render(
|
|
127
|
+
wrap(
|
|
128
|
+
<ProjectMenu
|
|
129
|
+
projects={[
|
|
130
|
+
{ name: "website", label: "Marketing site" },
|
|
131
|
+
{ name: "docs", label: "docs" },
|
|
132
|
+
]}
|
|
133
|
+
current="website"
|
|
134
|
+
onSelect={() => {}}
|
|
135
|
+
/>,
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
fireEvent.click(screen.getByTestId("project-trigger"));
|
|
139
|
+
expect(screen.getByTestId("project-option-website")).toHaveTextContent("Marketing site");
|
|
140
|
+
expect(screen.getByTestId("project-option-website")).toHaveTextContent("website");
|
|
141
|
+
expect(screen.queryByTestId("project-new-window-website")).not.toBeInTheDocument();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("window chrome keeps the leading edge clear and marks the bar as the drag region", () => {
|
|
145
|
+
render(wrap(<ContentBrowser {...base} windowChrome={{ leadingInset: 80, height: 52 }} />));
|
|
146
|
+
const bar = screen.getByTestId("top-bar");
|
|
147
|
+
expect(bar).toHaveAttribute("data-window-drag", "true");
|
|
148
|
+
// The bar's own padding (12) plus the inset.
|
|
149
|
+
expect(bar.style.paddingLeft).toBe("92px");
|
|
150
|
+
// As tall as the title bar whose controls it carries.
|
|
151
|
+
expect(bar.style.height).toBe("52px");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("without window chrome the bar keeps the layout's height", () => {
|
|
155
|
+
render(wrap(<ContentBrowser {...base} />));
|
|
156
|
+
expect(screen.getByTestId("top-bar").style.height).toBe("48px");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("workspace initials come from the first and last words", () => {
|
|
160
|
+
expect(workspaceInitials("Acme Inc")).toBe("AI");
|
|
161
|
+
expect(workspaceInitials("acme")).toBe("AC");
|
|
162
|
+
expect(workspaceInitials("Big Old Workspace")).toBe("BW");
|
|
163
|
+
expect(workspaceInitials(" ")).toBe("?");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("the standalone workspace menu works outside the content browser", () => {
|
|
167
|
+
const onSelect = jest.fn();
|
|
168
|
+
render(wrap(<WorkspaceMenu workspaces={workspaces} current="w2" onSelect={onSelect} testID="ws" />));
|
|
169
|
+
expect(screen.getByTestId("ws-name")).toHaveTextContent("Side Project");
|
|
170
|
+
fireEvent.click(screen.getByTestId("ws-menu"));
|
|
171
|
+
// The backdrop dismisses without choosing.
|
|
172
|
+
fireEvent.click(screen.getByTestId("ws-backdrop"));
|
|
173
|
+
expect(screen.queryByTestId("ws-menu-list")).not.toBeInTheDocument();
|
|
174
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("a folder stands where the workspace would, with its path and actions", () => {
|
|
178
|
+
const onOpenFolder = jest.fn();
|
|
179
|
+
render(
|
|
180
|
+
wrap(
|
|
181
|
+
<ContentBrowser
|
|
182
|
+
{...base}
|
|
183
|
+
workspaceMenu={{
|
|
184
|
+
kind: "folder",
|
|
185
|
+
workspaces: [{ id: "folder", name: "site", slug: "/Users/ada/code/site" }],
|
|
186
|
+
current: "folder",
|
|
187
|
+
onSelect: () => {},
|
|
188
|
+
actions: [{ label: "Open Folder…", icon: "folder", onSelect: onOpenFolder, testID: "folder-open" }],
|
|
189
|
+
testID: "folder",
|
|
190
|
+
}}
|
|
191
|
+
/>,
|
|
192
|
+
),
|
|
193
|
+
);
|
|
194
|
+
expect(screen.getByTestId("folder-name")).toHaveTextContent("site");
|
|
195
|
+
expect(screen.getByLabelText("Folder site")).toBeInTheDocument();
|
|
196
|
+
// No monogram: a folder is not an organisation.
|
|
197
|
+
expect(screen.queryByText("SI")).not.toBeInTheDocument();
|
|
198
|
+
fireEvent.click(screen.getByTestId("folder-menu"));
|
|
199
|
+
expect(screen.getByTestId("folder-menu-list")).toHaveTextContent("Folder");
|
|
200
|
+
expect(screen.getByTestId("folder-menu-list")).toHaveTextContent("/Users/ada/code/site");
|
|
201
|
+
fireEvent.click(screen.getByTestId("folder-open"));
|
|
202
|
+
expect(onOpenFolder).toHaveBeenCalled();
|
|
203
|
+
});
|
|
@@ -97,7 +97,9 @@ describe("sidebar section", () => {
|
|
|
97
97
|
];
|
|
98
98
|
const entry: CmsEntry = { id: "a", path: "posts/a.md", title: "Alpha", body: "", fields: [] };
|
|
99
99
|
|
|
100
|
-
|
|
100
|
+
// The sections are on screen while nothing is pushed into the master pane,
|
|
101
|
+
// so the button tests render with no active key.
|
|
102
|
+
function renderShell(api: MediaApi | undefined, activeNavKey = "") {
|
|
101
103
|
const onSelectNav = jest.fn();
|
|
102
104
|
render(
|
|
103
105
|
<ThemeProvider>
|
|
@@ -156,8 +158,9 @@ describe("sidebar section", () => {
|
|
|
156
158
|
// Nothing chosen yet, so the details pane says so rather than showing a
|
|
157
159
|
// browser for an arbitrary set.
|
|
158
160
|
expect(screen.getByTestId("media-sets-empty")).toBeInTheDocument();
|
|
159
|
-
// The document
|
|
160
|
-
expect(screen.queryByTestId("
|
|
161
|
+
// The document list and columns are gone — there is no document to edit here.
|
|
162
|
+
expect(screen.queryByTestId("entries-list")).not.toBeInTheDocument();
|
|
163
|
+
expect(screen.queryByTestId("pane-detail")).not.toBeInTheDocument();
|
|
161
164
|
});
|
|
162
165
|
|
|
163
166
|
test("choosing a collection reports its key to the host", () => {
|
|
@@ -174,13 +177,16 @@ describe("sidebar section", () => {
|
|
|
174
177
|
expect(screen.queryByTestId("media-sets-empty")).not.toBeInTheDocument();
|
|
175
178
|
});
|
|
176
179
|
|
|
177
|
-
// The set is not a
|
|
178
|
-
//
|
|
179
|
-
test("
|
|
180
|
+
// The set is not a row of its own, so without this the title switcher would
|
|
181
|
+
// mark nothing current while media is plainly on screen.
|
|
182
|
+
test("Media reads as current while a collection is open", () => {
|
|
180
183
|
renderShell(makeApi(), "media:uploads");
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
+
// The pushed list is headed "Media"...
|
|
185
|
+
expect(screen.getByTestId("master-title")).toHaveTextContent("Media");
|
|
186
|
+
// ...and its switcher marks Media, not an unrelated collection.
|
|
187
|
+
fireEvent.click(screen.getByTestId("master-title"));
|
|
188
|
+
expect(screen.getByTestId("master-option-media:")).toHaveAttribute("aria-current", "page");
|
|
189
|
+
expect(screen.getByTestId("master-option-posts")).not.toHaveAttribute("aria-current");
|
|
184
190
|
});
|
|
185
191
|
});
|
|
186
192
|
|
|
@@ -66,3 +66,17 @@ test("cancel dismisses", () => {
|
|
|
66
66
|
fireEvent.click(screen.getByTestId("protected-branch-cancel"));
|
|
67
67
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
68
68
|
});
|
|
69
|
+
|
|
70
|
+
// The repository decides where its branches live. The prefix is shown, not
|
|
71
|
+
// typed: the field holds only what follows it, and the name handed back is the
|
|
72
|
+
// whole — so a suggestion that already carries the prefix is not doubled.
|
|
73
|
+
test("a branch prefix is shown read-only and applied to the name", () => {
|
|
74
|
+
const onCreate = jest.fn();
|
|
75
|
+
render(wrap(<ProtectedBranchModal {...base} prefix="cms/" suggestedName="cms/edit/hello" onCreate={onCreate} />));
|
|
76
|
+
|
|
77
|
+
expect(screen.getByTestId("protected-branch-name-prefix")).toHaveTextContent("cms/");
|
|
78
|
+
expect(screen.getByTestId("protected-branch-name")).toHaveValue("edit/hello");
|
|
79
|
+
fireEvent.change(screen.getByTestId("protected-branch-name"), { target: { value: "spring" } });
|
|
80
|
+
fireEvent.click(screen.getByTestId("protected-branch-submit"));
|
|
81
|
+
expect(onCreate).toHaveBeenCalledWith("cms/spring");
|
|
82
|
+
});
|
|
@@ -169,6 +169,7 @@ describe("scales", () => {
|
|
|
169
169
|
it("has the layout dims from theme.ts", () => {
|
|
170
170
|
const map: Record<string, keyof typeof lightTheme.layout> = {
|
|
171
171
|
"--sidebar-w": "sidebar",
|
|
172
|
+
"--master-w": "master",
|
|
172
173
|
"--column-w": "column",
|
|
173
174
|
"--topbar-h": "topbar",
|
|
174
175
|
"--container": "container",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The drafts store is shared by every page of an origin, such as two editor
|
|
2
|
+
// tabs. What one page does to it must not set the other off.
|
|
3
|
+
|
|
4
|
+
const PROBE = "gitcms.draft.v1:probe";
|
|
5
|
+
|
|
6
|
+
function freshDrafts(): typeof import("../components/documentDrafts") {
|
|
7
|
+
let mod: typeof import("../components/documentDrafts") | undefined;
|
|
8
|
+
jest.isolateModules(() => {
|
|
9
|
+
mod = require("../components/documentDrafts");
|
|
10
|
+
});
|
|
11
|
+
return mod!;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
jest.restoreAllMocks();
|
|
16
|
+
localStorage.clear();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("the store is probed once, not on every read", () => {
|
|
20
|
+
const drafts = freshDrafts();
|
|
21
|
+
const setItem = jest.spyOn(Storage.prototype, "setItem");
|
|
22
|
+
drafts.subscribeDrafts(() => {});
|
|
23
|
+
drafts.hasDraft("a");
|
|
24
|
+
drafts.hasDraft("b");
|
|
25
|
+
drafts.draftCount();
|
|
26
|
+
drafts.refreshDrafts();
|
|
27
|
+
drafts.hasDraft("a");
|
|
28
|
+
expect(setItem.mock.calls.filter(([key]) => key === PROBE)).toHaveLength(1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("another page's probe is not a draft, and is not answered", () => {
|
|
32
|
+
const drafts = freshDrafts();
|
|
33
|
+
const onChange = jest.fn();
|
|
34
|
+
drafts.subscribeDrafts(onChange);
|
|
35
|
+
drafts.hasDraft("a");
|
|
36
|
+
const setItem = jest.spyOn(Storage.prototype, "setItem");
|
|
37
|
+
|
|
38
|
+
window.dispatchEvent(new StorageEvent("storage", { key: PROBE, newValue: "1" }));
|
|
39
|
+
window.dispatchEvent(new StorageEvent("storage", { key: PROBE, newValue: null }));
|
|
40
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
41
|
+
expect(drafts.draftCount()).toBe(0);
|
|
42
|
+
expect(setItem).not.toHaveBeenCalled();
|
|
43
|
+
|
|
44
|
+
// Another page's draft is a change worth showing, and reading after it still
|
|
45
|
+
// writes nothing.
|
|
46
|
+
localStorage.setItem("gitcms.draft.v1:doc-1", JSON.stringify({ v: 1, values: {}, at: 1 }));
|
|
47
|
+
setItem.mockClear();
|
|
48
|
+
window.dispatchEvent(new StorageEvent("storage", { key: "gitcms.draft.v1:doc-1" }));
|
|
49
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
50
|
+
expect(drafts.hasDraft("doc-1")).toBe(true);
|
|
51
|
+
expect(setItem).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("a probe caught mid-flight is not counted as a draft", () => {
|
|
55
|
+
const drafts = freshDrafts();
|
|
56
|
+
localStorage.setItem(PROBE, "1");
|
|
57
|
+
drafts.refreshDrafts();
|
|
58
|
+
expect(drafts.draftCount()).toBe(0);
|
|
59
|
+
});
|
|
@@ -19,8 +19,17 @@ export type BranchMenuProps = {
|
|
|
19
19
|
/** Marked in the list; it is also the base every branch's changes compare to. */
|
|
20
20
|
defaultBranch?: string;
|
|
21
21
|
onSelect?: (branch: BranchRef) => void;
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Omitted → no "New branch" row. Receives the full branch name — the prefix,
|
|
24
|
+
* when there is one, already applied. Rejecting the name should throw.
|
|
25
|
+
*/
|
|
23
26
|
onCreate?: (name: string) => Promise<void> | void;
|
|
27
|
+
/**
|
|
28
|
+
* The repository's branch prefix ("cms/"). Shown read-only ahead of the name
|
|
29
|
+
* field: branches the editor creates are always named under it, so the
|
|
30
|
+
* author types only what follows and cannot leave it out or get it wrong.
|
|
31
|
+
*/
|
|
32
|
+
prefix?: string;
|
|
24
33
|
/**
|
|
25
34
|
* testID prefix for the trigger and the badge, so two menus in the same bar
|
|
26
35
|
* (source and target) are addressable apart. Defaults to "branch"; a caller
|
|
@@ -37,7 +46,7 @@ export type BranchMenuProps = {
|
|
|
37
46
|
* Branch names are metadata and always render mono, per the system's rule that
|
|
38
47
|
* paths, hashes and refs are set in Geist Mono.
|
|
39
48
|
*/
|
|
40
|
-
export function BranchMenu({ current, branches, defaultBranch, onSelect, onCreate, testID = "branch" }: BranchMenuProps) {
|
|
49
|
+
export function BranchMenu({ current, branches, defaultBranch, onSelect, onCreate, prefix, testID = "branch" }: BranchMenuProps) {
|
|
41
50
|
const t = useTheme();
|
|
42
51
|
const [open, setOpen] = useState(false);
|
|
43
52
|
const [creating, setCreating] = useState(false);
|
|
@@ -80,7 +89,7 @@ export function BranchMenu({ current, branches, defaultBranch, onSelect, onCreat
|
|
|
80
89
|
setBusy(true);
|
|
81
90
|
setError(null);
|
|
82
91
|
try {
|
|
83
|
-
await onCreate(trimmed);
|
|
92
|
+
await onCreate((prefix ?? "") + trimmed);
|
|
84
93
|
close();
|
|
85
94
|
} catch (e: unknown) {
|
|
86
95
|
setError((e as { message?: string })?.message || "Couldn’t create the branch.");
|
|
@@ -120,9 +129,10 @@ export function BranchMenu({ current, branches, defaultBranch, onSelect, onCreat
|
|
|
120
129
|
label="New branch"
|
|
121
130
|
value={name}
|
|
122
131
|
onChangeText={setName}
|
|
123
|
-
placeholder="feat/my-change"
|
|
132
|
+
placeholder={prefix ? "my-change" : "feat/my-change"}
|
|
124
133
|
mono
|
|
125
134
|
error={!!error}
|
|
135
|
+
prefix={prefix}
|
|
126
136
|
testID={`${testID}-create-input`}
|
|
127
137
|
/>
|
|
128
138
|
<Text variant="monoSm" color="tertiary">
|