@gogitcms/design-system 0.16.0-next.1 → 0.16.0-next.11

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 (31) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/ApplyChangesModal.test.tsx +48 -0
  3. package/src/__tests__/BranchDeletedModal.test.tsx +42 -0
  4. package/src/__tests__/ContentBrowser.fieldfocus.test.tsx +109 -0
  5. package/src/__tests__/ContentBrowser.forms.test.tsx +47 -1
  6. package/src/__tests__/ContentBrowser.history.test.tsx +385 -0
  7. package/src/__tests__/ContentBrowser.historycollab.test.tsx +433 -0
  8. package/src/__tests__/ContentBrowser.references.test.tsx +179 -0
  9. package/src/__tests__/ContentBrowser.slotmedia.test.tsx +150 -0
  10. package/src/__tests__/MediaField.test.tsx +18 -0
  11. package/src/__tests__/ProtectedBranchModal.test.tsx +68 -0
  12. package/src/components/ApplyChangesModal.tsx +70 -21
  13. package/src/components/BranchDeletedModal.tsx +111 -0
  14. package/src/components/ChangeDetail.tsx +94 -11
  15. package/src/components/CollabField.tsx +71 -0
  16. package/src/components/ContentBrowser.tsx +494 -77
  17. package/src/components/DocumentDetails.tsx +0 -0
  18. package/src/components/DocumentHistory.tsx +408 -0
  19. package/src/components/MediaField.tsx +62 -0
  20. package/src/components/MediaPreview.tsx +14 -1
  21. package/src/components/MediaPreview.web.tsx +18 -3
  22. package/src/components/Notifications.tsx +146 -28
  23. package/src/components/ProtectedBranchModal.tsx +136 -0
  24. package/src/components/ReferenceField.tsx +0 -0
  25. package/src/components/primitives.tsx +46 -1
  26. package/src/fieldComponents.ts +2 -2
  27. package/src/history.ts +82 -0
  28. package/src/icons.ts +1 -0
  29. package/src/index.ts +28 -3
  30. package/src/media.ts +15 -0
  31. package/src/references.ts +97 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gogitcms/design-system",
3
- "version": "0.16.0-next.1",
3
+ "version": "0.16.0-next.11",
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.",
@@ -146,3 +146,51 @@ test("failure surfaces the error and a dismiss button", () => {
146
146
  expect(screen.getByTestId("merge-error")).toHaveTextContent("main moved while merging");
147
147
  expect(screen.getByTestId("apply-dismiss")).toBeInTheDocument();
148
148
  });
149
+
150
+ test("with a review reason the confirmation step promises a review, not a merge commit", () => {
151
+ render(
152
+ wrap(
153
+ <ApplyChangesModal
154
+ {...branches}
155
+ reviewReason="main is protected, so this opens a change request for review."
156
+ onConfirm={() => {}}
157
+ onClose={() => {}}
158
+ />,
159
+ ),
160
+ );
161
+
162
+ const confirm = screen.getByTestId("apply-confirm");
163
+ // The run will stop before the push, so the copy must not describe one.
164
+ expect(confirm).not.toHaveTextContent("pushes a merge commit");
165
+ expect(confirm).not.toHaveTextContent("is then deleted");
166
+ expect(screen.getByTestId("apply-confirm-review")).toHaveTextContent("opens a pull request for review");
167
+ expect(confirm).toHaveTextContent("Nothing is pushed to origin/main until it is approved");
168
+ expect(confirm).toHaveTextContent("main is protected");
169
+ });
170
+
171
+ test("needs_review without code conflicts explains the review instead of listing files", () => {
172
+ const onCreateChangeRequest = jest.fn();
173
+ render(
174
+ wrap(
175
+ <ApplyChangesModal
176
+ {...branches}
177
+ reviewReason="main is protected, so this opens a change request for review."
178
+ progress={{ ...base, status: "needs_review", step: "merging", escalationFiles: [] }}
179
+ onCreateChangeRequest={onCreateChangeRequest}
180
+ onClose={() => {}}
181
+ />,
182
+ ),
183
+ );
184
+
185
+ const panel = screen.getByTestId("merge-needs-review");
186
+ // No conflict: it is not called one, and no empty file list is drawn.
187
+ expect(panel).not.toHaveTextContent("Code conflicts need a developer");
188
+ expect(panel).toHaveTextContent("This merge needs a review");
189
+ expect(screen.getByTestId("merge-needs-review-reason")).toHaveTextContent("nothing was pushed to main");
190
+ expect(screen.getByTestId("merge-needs-review-reason")).toHaveTextContent("main is protected");
191
+
192
+ // The same escalation flow: an explanation, then a change request.
193
+ fireEvent.change(screen.getByTestId("change-request-explanation"), { target: { value: "Spring copy" } });
194
+ fireEvent.click(screen.getByTestId("create-change-request-submit"));
195
+ expect(onCreateChangeRequest).toHaveBeenCalledWith("Spring copy");
196
+ });
@@ -0,0 +1,42 @@
1
+ import React from "react";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import { ThemeProvider } from "../ThemeProvider";
4
+ import { BranchDeletedModal } from "../components/BranchDeletedModal";
5
+
6
+ const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
7
+
8
+ const others = [
9
+ { id: "b-main", name: "main" },
10
+ { id: "b-draft", name: "draft/spring" },
11
+ ];
12
+
13
+ test("names the deleted branch and switches to the one picked", () => {
14
+ const onSwitch = jest.fn();
15
+ render(wrap(
16
+ <BranchDeletedModal branch="feat/pricing" repository="acme/site" branches={others} onSwitch={onSwitch} />,
17
+ ));
18
+
19
+ expect(screen.getByTestId("branch-deleted-title")).toHaveTextContent("feat/pricing was deleted");
20
+ expect(screen.getByText(/removed with it/)).toBeInTheDocument();
21
+
22
+ fireEvent.click(screen.getByTestId("branch-deleted-switch-b-draft"));
23
+ expect(onSwitch).toHaveBeenCalledWith(others[1]);
24
+ // The repository exit is not offered unless the host provides it.
25
+ expect(screen.queryByTestId("branch-deleted-choose-repository")).toBeNull();
26
+ });
27
+
28
+ test("a stale link (no branch name) still explains and offers the others", () => {
29
+ render(wrap(<BranchDeletedModal branches={others} onSwitch={() => {}} />));
30
+ expect(screen.getByTestId("branch-deleted-title")).toHaveTextContent("This branch no longer exists");
31
+ expect(screen.getByTestId("branch-deleted-switch-b-main")).toBeInTheDocument();
32
+ });
33
+
34
+ test("with nothing to switch to, the repository picker is the exit", () => {
35
+ const onChoose = jest.fn();
36
+ render(wrap(
37
+ <BranchDeletedModal branch="main" repository="acme/site" branches={[]} onSwitch={() => {}} onChooseRepository={onChoose} />,
38
+ ));
39
+ expect(screen.getByTestId("branch-deleted-none")).toHaveTextContent("No other branch of acme/site");
40
+ fireEvent.click(screen.getByTestId("branch-deleted-choose-repository"));
41
+ expect(onChoose).toHaveBeenCalled();
42
+ });
@@ -0,0 +1,109 @@
1
+ // Which field the author is in, reported as a dotted path — what a preview
2
+ // pane uses to ring the matching part of the page. Read off the DOM's focus
3
+ // events and the `data-cms-field` attribute every control's wrapper carries,
4
+ // so it works for every kind of control and for a lone author with no live
5
+ // session.
6
+ import React from "react";
7
+ import { render, screen, fireEvent, act } from "@testing-library/react";
8
+ import { ThemeProvider } from "../ThemeProvider";
9
+ import { ContentBrowser, type CmsEntry, type CmsNavSection, type EntryField } from "../components/ContentBrowser";
10
+
11
+ jest.mock("../ThemeProvider", () => {
12
+ const actual = jest.requireActual("../ThemeProvider");
13
+ return { ...actual, useResponsive: () => ({ width: 1300, height: 900, isDesktop: true, isMobile: false }) };
14
+ });
15
+
16
+ const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
17
+
18
+ const sections: CmsNavSection[] = [
19
+ { title: "Content", items: [{ key: "pages", label: "Pages", icon: "newspaper" }] },
20
+ ];
21
+
22
+ const fields: EntryField[] = [
23
+ { name: "title", label: "Title", type: "string", value: "Home", required: true },
24
+ {
25
+ name: "blocks",
26
+ label: "Blocks",
27
+ type: "array",
28
+ component: "mixedList",
29
+ value: [{ _variant: "hero", heading: "Hi", stats: [{ label: "a", value: "1" }] }],
30
+ variants: [
31
+ {
32
+ name: "hero",
33
+ fields: [
34
+ { name: "heading", label: "Heading", type: "string", value: null },
35
+ {
36
+ name: "stats",
37
+ label: "Stats",
38
+ type: "array",
39
+ of: "object",
40
+ value: null,
41
+ fields: [
42
+ { name: "label", label: "Label", type: "string", value: null },
43
+ { name: "value", label: "Value", type: "string", value: null },
44
+ ],
45
+ },
46
+ ],
47
+ },
48
+ ],
49
+ },
50
+ ];
51
+
52
+ const entry: CmsEntry = { id: "doc-1", path: "content/pages/home.json", title: "Home", body: "", fields };
53
+
54
+ function renderBrowser(onFieldFocus = jest.fn()) {
55
+ render(
56
+ wrap(
57
+ <ContentBrowser
58
+ workspace={{ name: "acme/site", initials: "AC", branch: "main", changed: 0 }}
59
+ sections={sections}
60
+ activeNavKey="pages"
61
+ onSelectNav={() => {}}
62
+ entries={[entry]}
63
+ userInitials="ED"
64
+ onSaveEntry={jest.fn()}
65
+ onFieldFocus={onFieldFocus}
66
+ />,
67
+ ),
68
+ );
69
+ return onFieldFocus;
70
+ }
71
+
72
+ // The blur report is deferred a tick so a move between fields never passes
73
+ // through null; flush it.
74
+ const settle = () => act(() => new Promise((r) => setTimeout(r, 5)));
75
+
76
+ describe("onFieldFocus", () => {
77
+ it("reports a top-level field by name and null when it blurs", async () => {
78
+ const onFieldFocus = renderBrowser();
79
+ const title = screen.getByDisplayValue("Home");
80
+ fireEvent.focusIn(title);
81
+ expect(onFieldFocus).toHaveBeenLastCalledWith("doc-1", "title");
82
+ fireEvent.focusOut(title);
83
+ await settle();
84
+ expect(onFieldFocus).toHaveBeenLastCalledWith("doc-1", null);
85
+ });
86
+
87
+ it("composes the path of a field inside a mixed-list item and a nested list", async () => {
88
+ const onFieldFocus = renderBrowser();
89
+ const heading = screen.getByDisplayValue("Hi");
90
+ fireEvent.focusIn(heading);
91
+ expect(onFieldFocus).toHaveBeenLastCalledWith("doc-1", "blocks.0.heading");
92
+
93
+ const statLabel = screen.getByDisplayValue("a");
94
+ // Straight from one field to the next: no null in between.
95
+ fireEvent.focusOut(heading);
96
+ fireEvent.focusIn(statLabel);
97
+ await settle();
98
+ expect(onFieldFocus).not.toHaveBeenCalledWith("doc-1", null);
99
+ expect(onFieldFocus).toHaveBeenLastCalledWith("doc-1", "blocks.0.stats.0.label");
100
+ });
101
+
102
+ it("annotates every control's wrapper with its path", () => {
103
+ renderBrowser();
104
+ const paths = Array.from(document.querySelectorAll("[data-cms-field]")).map((el) => el.getAttribute("data-cms-field"));
105
+ expect(paths).toEqual(
106
+ expect.arrayContaining(["title", "blocks", "blocks.0", "blocks.0.heading", "blocks.0.stats", "blocks.0.stats.0", "blocks.0.stats.0.label", "blocks.0.stats.0.value"]),
107
+ );
108
+ });
109
+ });
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { render, screen, fireEvent } from "@testing-library/react";
2
+ import { render, screen, fireEvent, within } from "@testing-library/react";
3
3
  import { ThemeProvider } from "../ThemeProvider";
4
4
  import { ContentBrowser, type CmsEntry, type CmsNavSection } from "../components/ContentBrowser";
5
5
  import { FORMS_NAV_KEY, formsNavKey, type FormInfo, type FormsApi } from "../forms";
@@ -109,3 +109,49 @@ test("an open form takes the whole content area", async () => {
109
109
  expect(screen.queryByTestId("forms-empty")).not.toBeInTheDocument();
110
110
  expect(screen.queryByTestId("resize-forms")).not.toBeInTheDocument();
111
111
  });
112
+
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.
116
+ test("the forms heading matches a collection's, not a smaller indented one", () => {
117
+ const { unmount } = render(
118
+ wrap(
119
+ <ContentBrowser
120
+ workspace={{ name: "acme/site", initials: "AC", branch: "main", changed: 0 }}
121
+ sections={sections}
122
+ activeNavKey={FORMS_NAV_KEY}
123
+ onSelectNav={() => {}}
124
+ entries={entries}
125
+ userInitials="ED"
126
+ forms={makeApi()}
127
+ />,
128
+ ),
129
+ );
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");
132
+ // The heading sits directly in Pane's header row — the row with the pane's
133
+ // height and the rule under it. A padded wrapper of its own in between is
134
+ // what used to push this title a step right of every collection's.
135
+ expect(formsHeading.parentElement).toHaveStyle({ height: "48px", paddingLeft: "16px" });
136
+ const formsStyle = window.getComputedStyle(formsHeading);
137
+ unmount();
138
+
139
+ render(
140
+ wrap(
141
+ <ContentBrowser
142
+ workspace={{ name: "acme/site", initials: "AC", branch: "main", changed: 0 }}
143
+ sections={sections}
144
+ activeNavKey="posts"
145
+ onSelectNav={() => {}}
146
+ entries={entries}
147
+ userInitials="ED"
148
+ />,
149
+ ),
150
+ );
151
+ const collectionHeading = within(screen.getByTestId("pane-entries")).getByText("Posts");
152
+ const collectionStyle = window.getComputedStyle(collectionHeading);
153
+
154
+ expect(formsStyle.fontSize).toBe(collectionStyle.fontSize);
155
+ expect(formsStyle.fontWeight).toBe(collectionStyle.fontWeight);
156
+ expect(formsStyle.flexGrow).toBe(collectionStyle.flexGrow);
157
+ });
@@ -0,0 +1,385 @@
1
+ import React from "react";
2
+ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
3
+ import { ThemeProvider } from "../ThemeProvider";
4
+ import { ContentBrowser, type CmsEntry, type CmsNavSection } from "../components/ContentBrowser";
5
+ import type { DocumentVersion, HistoryApi } from "../history";
6
+ import { refreshDrafts } from "../components/documentDrafts";
7
+ import type { DocumentChange } from "../components/ChangeDetail";
8
+
9
+ // Force the desktop layout (jsdom reports width 0 → mobile otherwise).
10
+ jest.mock("../ThemeProvider", () => {
11
+ const actual = jest.requireActual("../ThemeProvider");
12
+ return { ...actual, useResponsive: () => ({ width: 1300, height: 900, isDesktop: true, isMobile: false }) };
13
+ });
14
+
15
+ const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
16
+
17
+ // A restore commits, and committing records a draft. Without clearing them each
18
+ // test seeds its form from the PREVIOUS test's restore — which reads as a field
19
+ // that was already rolled back and so has nothing left to write.
20
+ beforeEach(() => {
21
+ localStorage.clear();
22
+ refreshDrafts();
23
+ });
24
+
25
+ const sections: CmsNavSection[] = [
26
+ { title: "Content", items: [{ key: "posts", label: "Posts", icon: "newspaper" }] },
27
+ ];
28
+
29
+ const entry: CmsEntry = {
30
+ id: "doc-1",
31
+ path: "posts/hello.md",
32
+ title: "Hello",
33
+ body: "",
34
+ fields: [
35
+ { name: "title", type: "string", value: "Hello" },
36
+ { name: "status", type: "string", value: "published" },
37
+ { name: "body", type: "string", component: "body", source: "body", value: "Current body." },
38
+ ],
39
+ };
40
+
41
+ const published: DocumentVersion = {
42
+ sha: "9f2c1abcdef",
43
+ shortSha: "9f2c1ab",
44
+ message: "Publish the post\n\nA body the list has no room for.",
45
+ authorName: "Ada",
46
+ authoredAt: "2026-03-01T12:00:00Z",
47
+ url: "https://github.com/acme/site/commit/9f2c1ab",
48
+ filesChanged: 3,
49
+ };
50
+
51
+ const drafted: DocumentVersion = {
52
+ sha: "3b7de1098877",
53
+ shortSha: "3b7de10",
54
+ message: "Draft the post",
55
+ authorName: "Ada",
56
+ authoredAt: "2026-02-01T12:00:00Z",
57
+ url: "https://github.com/acme/site/commit/3b7de10",
58
+ filesChanged: 1,
59
+ };
60
+
61
+ const draftedDetail: DocumentChange = {
62
+ path: "posts/hello.md",
63
+ status: "M",
64
+ label: "Hello",
65
+ added: 1,
66
+ removed: 1,
67
+ fields: [
68
+ { name: "title", kind: "unchanged", before: "Hello", after: "Hello" },
69
+ { name: "status", kind: "changed", before: "draft", after: "published" },
70
+ ],
71
+ bodyBefore: "The earlier body.",
72
+ bodyAfter: "Current body.",
73
+ };
74
+
75
+ function makeApi(over: Partial<HistoryApi> = {}): HistoryApi {
76
+ return {
77
+ list: jest.fn(async () => [published, drafted]),
78
+ get: jest.fn(async () => draftedDetail),
79
+ ...over,
80
+ };
81
+ }
82
+
83
+ function renderBrowser(history?: HistoryApi, over: { readOnly?: boolean } = {}) {
84
+ const onSaveEntry = jest.fn(async () => {});
85
+ render(
86
+ wrap(
87
+ <ContentBrowser
88
+ onSaveEntry={onSaveEntry}
89
+ readOnly={over.readOnly}
90
+ workspace={{ name: "acme/site", initials: "AC", branch: "main", changed: 0 }}
91
+ sections={sections}
92
+ activeNavKey="posts"
93
+ onSelectNav={jest.fn()}
94
+ entries={[entry]}
95
+ selectedEntryId={entry.id}
96
+ onSelectEntry={jest.fn()}
97
+ userInitials="ED"
98
+ history={history}
99
+ />,
100
+ ),
101
+ );
102
+ return onSaveEntry;
103
+ }
104
+
105
+ /**
106
+ * Opens history and waits for the newest version's diff to actually land — the
107
+ * pane mounts before its first fetch resolves, and everything to do with
108
+ * restoring is rendered from the fetched diff.
109
+ */
110
+ async function openHistory(history?: HistoryApi, over: { readOnly?: boolean } = {}) {
111
+ const onSaveEntry = renderBrowser(history ?? makeApi(), over);
112
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
113
+ await screen.findByTestId("document-history");
114
+ await screen.findByTestId("change-detail");
115
+ return onSaveEntry;
116
+ }
117
+
118
+ // The affordance only exists where history does. A deployment with no provider
119
+ // should show no button at all rather than one that errors when pressed.
120
+ test("no history seam means no history button", () => {
121
+ renderBrowser(undefined);
122
+ expect(screen.queryByTestId("document-history-toggle")).not.toBeInTheDocument();
123
+ });
124
+
125
+ test("the history button gives the document's column over to the version browser", async () => {
126
+ renderBrowser(makeApi());
127
+
128
+ // The editor form is what a column shows until history is asked for.
129
+ expect(screen.getByTestId("column-scroll")).toBeInTheDocument();
130
+ expect(screen.queryByTestId("document-history")).not.toBeInTheDocument();
131
+
132
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
133
+
134
+ expect(await screen.findByTestId("document-history")).toBeInTheDocument();
135
+ // The form is gone — history takes the column's body, not a slice of it.
136
+ expect(screen.queryByTestId("column-scroll")).not.toBeInTheDocument();
137
+ });
138
+
139
+ test("the version list shows each commit's subject, author, sha and file count", async () => {
140
+ renderBrowser(makeApi());
141
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
142
+
143
+ expect(await screen.findByTestId("version-9f2c1ab")).toBeInTheDocument();
144
+ expect(screen.getByTestId("version-3b7de10")).toBeInTheDocument();
145
+
146
+ // The subject only — the message body would push every other row off screen.
147
+ expect(screen.getByText("Publish the post")).toBeInTheDocument();
148
+ expect(screen.queryByText(/A body the list has no room for/)).not.toBeInTheDocument();
149
+
150
+ expect(screen.getByText("3 files")).toBeInTheDocument();
151
+ // Singular, because "1 files" is the kind of thing people notice.
152
+ expect(screen.getByText("1 file")).toBeInTheDocument();
153
+ });
154
+
155
+ // An empty diff beside a full list is a second click for nothing.
156
+ test("opening history selects the newest version and loads its diff", async () => {
157
+ const api = makeApi();
158
+ renderBrowser(api);
159
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
160
+
161
+ await waitFor(() => expect(api.get).toHaveBeenCalledWith({ documentId: "doc-1", sha: published.sha }));
162
+ expect(await screen.findByTestId("change-detail")).toBeInTheDocument();
163
+ });
164
+
165
+ test("selecting a version loads that version's document and diff", async () => {
166
+ const api = makeApi();
167
+ renderBrowser(api);
168
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
169
+
170
+ fireEvent.click(await screen.findByTestId("version-3b7de10"));
171
+
172
+ await waitFor(() => expect(api.get).toHaveBeenCalledWith({ documentId: "doc-1", sha: drafted.sha }));
173
+ // The version's value on the left of the diff, the current one on the right.
174
+ expect(await screen.findByText("draft")).toBeInTheDocument();
175
+ expect(screen.getByText("published")).toBeInTheDocument();
176
+ });
177
+
178
+ // A document with no commits is a real and common state — one created in the
179
+ // CMS and not yet exported. It must not read as a failure.
180
+ test("a document with no commits explains itself", async () => {
181
+ renderBrowser(makeApi({ list: jest.fn(async () => []) }));
182
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
183
+
184
+ expect(await screen.findByTestId("history-empty")).toBeInTheDocument();
185
+ expect(screen.queryByTestId("history-error")).not.toBeInTheDocument();
186
+ });
187
+
188
+ test("a failed history says so rather than showing an empty list", async () => {
189
+ renderBrowser(makeApi({ list: jest.fn(async () => { throw new Error("GitHub is unreachable"); }) }));
190
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
191
+
192
+ expect(await screen.findByText("GitHub is unreachable")).toBeInTheDocument();
193
+ expect(screen.queryByTestId("history-empty")).not.toBeInTheDocument();
194
+ });
195
+
196
+ test("the button toggles back, returning the column to the editor", async () => {
197
+ renderBrowser(makeApi());
198
+ const toggle = screen.getByTestId("document-history-toggle");
199
+
200
+ fireEvent.click(toggle);
201
+ expect(await screen.findByTestId("document-history")).toBeInTheDocument();
202
+
203
+ fireEvent.click(toggle);
204
+ expect(screen.queryByTestId("document-history")).not.toBeInTheDocument();
205
+ expect(screen.getByTestId("column-scroll")).toBeInTheDocument();
206
+ });
207
+
208
+ test("the pane's own close control leaves history too", async () => {
209
+ renderBrowser(makeApi());
210
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
211
+
212
+ fireEvent.click(await screen.findByTestId("history-close"));
213
+ expect(screen.queryByTestId("document-history")).not.toBeInTheDocument();
214
+ });
215
+
216
+ // ---- restoring fields from a past version ---------------------------------
217
+
218
+ // A tick means "take this version's value". An unchanged field's two sides are
219
+ // the same value, so a box on it could only ever be a no-op dressed up as a
220
+ // choice.
221
+ test("only changed fields offer a box", async () => {
222
+ await openHistory();
223
+
224
+ expect(screen.getByTestId("restore-field-status")).toBeInTheDocument();
225
+ expect(screen.getByTestId("restore-field-body")).toBeInTheDocument();
226
+ expect(screen.queryByTestId("restore-field-title")).not.toBeInTheDocument();
227
+ });
228
+
229
+ // The bar is mounted from the start rather than appearing on the first tick:
230
+ // otherwise it shifts the diff under the hand that just ticked something, and
231
+ // nothing tells you what the boxes are for until you have used one.
232
+ test("the restore bar is present but inert until something is picked", async () => {
233
+ await openHistory();
234
+
235
+ expect(screen.getByTestId("restore-bar")).toBeInTheDocument();
236
+ expect(screen.getByTestId("restore-apply")).toBeDisabled();
237
+ expect(screen.getByText("Tick a field to take its earlier value")).toBeInTheDocument();
238
+
239
+ fireEvent.click(screen.getByTestId("restore-field-status"));
240
+ expect(screen.getByTestId("restore-apply")).not.toBeDisabled();
241
+ expect(screen.getByText("1 field selected")).toBeInTheDocument();
242
+ });
243
+
244
+ test("restoring a field puts its old value in the editor and closes history", async () => {
245
+ await openHistory();
246
+
247
+ fireEvent.click(screen.getByTestId("restore-field-status"));
248
+ fireEvent.click(screen.getByTestId("restore-apply"));
249
+
250
+ // Back in the form, which is where the restored value now is.
251
+ expect(screen.queryByTestId("document-history")).not.toBeInTheDocument();
252
+ expect(screen.getByTestId("column-scroll")).toBeInTheDocument();
253
+
254
+ // The picked field took the version's value; the others are untouched.
255
+ expect(screen.getByDisplayValue("draft")).toBeInTheDocument();
256
+ expect(screen.getByDisplayValue("Hello")).toBeInTheDocument();
257
+ });
258
+
259
+ // The restore is an edit, not a write: it lands in the form and waits to be
260
+ // saved like anything the author typed.
261
+ test("a restore leaves the document unsaved rather than saving it", async () => {
262
+ const onSaveEntry = await openHistory();
263
+
264
+ fireEvent.click(screen.getByTestId("restore-field-status"));
265
+ fireEvent.click(screen.getByTestId("restore-apply"));
266
+
267
+ expect(onSaveEntry).not.toHaveBeenCalled();
268
+ await waitFor(() => expect(screen.getByTestId("save-entry")).not.toBeDisabled());
269
+ // And it can be thrown away like any other edit.
270
+ expect(screen.getByTestId("discard-changes")).toBeInTheDocument();
271
+
272
+ fireEvent.click(screen.getByTestId("save-entry"));
273
+ await waitFor(() =>
274
+ expect(onSaveEntry).toHaveBeenCalledWith(
275
+ expect.objectContaining({ fields: expect.objectContaining({ status: "draft" }) }),
276
+ ),
277
+ );
278
+ });
279
+
280
+ // The body is a field in the form like any other; which one it is comes from
281
+ // the schema's `source: "body"`.
282
+ test("the body restores onto the model's body field", async () => {
283
+ const onSaveEntry = await openHistory();
284
+
285
+ fireEvent.click(screen.getByTestId("restore-field-body"));
286
+ fireEvent.click(screen.getByTestId("restore-apply"));
287
+
288
+ fireEvent.click(screen.getByTestId("save-entry"));
289
+ await waitFor(() =>
290
+ expect(onSaveEntry).toHaveBeenCalledWith(expect.objectContaining({ body: "The earlier body." })),
291
+ );
292
+ });
293
+
294
+ test("several fields restore together", async () => {
295
+ const onSaveEntry = await openHistory();
296
+
297
+ fireEvent.click(screen.getByTestId("restore-field-status"));
298
+ fireEvent.click(screen.getByTestId("restore-field-body"));
299
+ expect(screen.getByText("2 fields selected")).toBeInTheDocument();
300
+ expect(screen.getByTestId("restore-apply")).toHaveTextContent("Restore 2 fields");
301
+
302
+ fireEvent.click(screen.getByTestId("restore-apply"));
303
+ fireEvent.click(screen.getByTestId("save-entry"));
304
+ await waitFor(() =>
305
+ expect(onSaveEntry).toHaveBeenCalledWith(
306
+ expect.objectContaining({
307
+ fields: expect.objectContaining({ status: "draft" }),
308
+ body: "The earlier body.",
309
+ }),
310
+ ),
311
+ );
312
+ });
313
+
314
+ test("unticking a field, and Clear, take it back out of the selection", async () => {
315
+ await openHistory();
316
+
317
+ fireEvent.click(screen.getByTestId("restore-field-status"));
318
+ fireEvent.click(screen.getByTestId("restore-field-status"));
319
+ expect(screen.getByTestId("restore-apply")).toBeDisabled();
320
+
321
+ fireEvent.click(screen.getByTestId("restore-field-status"));
322
+ fireEvent.click(screen.getByTestId("restore-clear"));
323
+ expect(screen.getByTestId("restore-apply")).toBeDisabled();
324
+ });
325
+
326
+ // A tick names a VALUE, not a field. Carrying it to the next version would keep
327
+ // the box ticked while silently repointing it at a different value.
328
+ test("switching version clears what was picked", async () => {
329
+ await openHistory();
330
+
331
+ fireEvent.click(screen.getByTestId("restore-field-status"));
332
+ expect(screen.getByTestId("restore-apply")).not.toBeDisabled();
333
+
334
+ fireEvent.click(screen.getByTestId("version-3b7de10"));
335
+ await waitFor(() => expect(screen.getByTestId("restore-apply")).toBeDisabled());
336
+ });
337
+
338
+ // A read-only document can be read back through but not rewritten.
339
+ test("a read-only document gets history with no restore at all", async () => {
340
+ await openHistory(makeApi(), { readOnly: true });
341
+
342
+ expect(screen.queryByTestId("restore-bar")).not.toBeInTheDocument();
343
+ expect(screen.queryByTestId("restore-field-status")).not.toBeInTheDocument();
344
+ });
345
+
346
+ // A version identical to the current document has no field whose earlier value
347
+ // is anything but the value already in the form.
348
+ test("an unchanged version offers nothing to restore", async () => {
349
+ const unchanged: DocumentChange = {
350
+ path: "posts/hello.md",
351
+ status: "U",
352
+ label: "Hello",
353
+ added: 0,
354
+ removed: 0,
355
+ fields: [{ name: "status", kind: "unchanged", before: "published", after: "published" }],
356
+ };
357
+ await openHistory(makeApi({ get: jest.fn(async () => unchanged) }));
358
+
359
+ expect(screen.queryByTestId("restore-bar")).not.toBeInTheDocument();
360
+ });
361
+
362
+ // Restoring needs somewhere for the values to go. A host that wired no save
363
+ // would otherwise offer a control that dirties a form nothing can persist.
364
+ test("a document with no save handler gets history with no restore", async () => {
365
+ render(
366
+ wrap(
367
+ <ContentBrowser
368
+ workspace={{ name: "acme/site", initials: "AC", branch: "main", changed: 0 }}
369
+ sections={sections}
370
+ activeNavKey="posts"
371
+ onSelectNav={jest.fn()}
372
+ entries={[entry]}
373
+ selectedEntryId={entry.id}
374
+ onSelectEntry={jest.fn()}
375
+ userInitials="ED"
376
+ history={makeApi()}
377
+ />,
378
+ ),
379
+ );
380
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
381
+ await screen.findByTestId("change-detail");
382
+
383
+ expect(screen.queryByTestId("restore-bar")).not.toBeInTheDocument();
384
+ expect(screen.queryByTestId("restore-field-status")).not.toBeInTheDocument();
385
+ });