@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.
@@ -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("both pane resize seams render on desktop", () => {
31
+ test("the master pane's resize seam renders on desktop", () => {
32
32
  render(wrap(<ContentBrowser {...base} />));
33
- expect(screen.getByTestId("resize-nav")).toBeInTheDocument();
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
- function renderShell(api: MediaApi | undefined, activeNavKey = "posts") {
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 panes are gone — there is no document to edit here.
160
- expect(screen.queryByTestId("pane-entries")).not.toBeInTheDocument();
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 sidebar row, so without this the sidebar would show nothing
178
- // selected while media is plainly on screen.
179
- test("the Media button stays active while a collection is open", () => {
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
- expect(screen.getByTestId("nav-media:")).toHaveAttribute("aria-current", "page");
182
- // ...and an unrelated collection is not.
183
- expect(screen.getByTestId("nav-posts")).not.toHaveAttribute("aria-current");
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
- /** Omitted → no "New branch" row. Rejecting the name should throw. */
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">