@gogitcms/design-system 0.15.0-next.3
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 +77 -0
- package/css/components.css +312 -0
- package/css/tokens.css +212 -0
- package/package.json +65 -0
- package/src/ThemeProvider.tsx +86 -0
- package/src/__tests__/ApplyChangesModal.test.tsx +148 -0
- package/src/__tests__/BranchImport.test.tsx +46 -0
- package/src/__tests__/Button.test.tsx +45 -0
- package/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
- package/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
- package/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
- package/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
- package/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
- package/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
- package/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
- package/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
- package/src/__tests__/ContentBrowser.media.test.tsx +140 -0
- package/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
- package/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
- package/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
- package/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
- package/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
- package/src/__tests__/ContentBrowser.search.test.tsx +135 -0
- package/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
- package/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
- package/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
- package/src/__tests__/MediaBrowser.test.tsx +353 -0
- package/src/__tests__/MediaField.test.tsx +185 -0
- package/src/__tests__/Notifications.test.tsx +69 -0
- package/src/__tests__/Onboarding.test.tsx +287 -0
- package/src/__tests__/cssTokens.test.ts +201 -0
- package/src/__tests__/fieldComponents.test.ts +43 -0
- package/src/__tests__/reorder.test.ts +58 -0
- package/src/components/ApplyChangesModal.tsx +348 -0
- package/src/components/BranchImport.tsx +157 -0
- package/src/components/BranchMenu.tsx +192 -0
- package/src/components/Button.tsx +131 -0
- package/src/components/ChangeDetail.tsx +472 -0
- package/src/components/ChangeRequestSummary.tsx +173 -0
- package/src/components/CollabField.tsx +388 -0
- package/src/components/ContentBrowser.tsx +5073 -0
- package/src/components/Icon.tsx +28 -0
- package/src/components/Icon.web.tsx +31 -0
- package/src/components/Input.tsx +106 -0
- package/src/components/MediaBrowser.tsx +766 -0
- package/src/components/MediaField.tsx +670 -0
- package/src/components/MediaPreview.tsx +91 -0
- package/src/components/MediaPreview.web.tsx +169 -0
- package/src/components/NavRow.tsx +105 -0
- package/src/components/Notifications.tsx +301 -0
- package/src/components/Onboarding.tsx +751 -0
- package/src/components/ProjectMenu.tsx +124 -0
- package/src/components/Segment.tsx +87 -0
- package/src/components/Skeleton.tsx +216 -0
- package/src/components/Spinner.tsx +44 -0
- package/src/components/Text.tsx +85 -0
- package/src/components/documentDrafts.ts +213 -0
- package/src/components/layout.tsx +284 -0
- package/src/components/primitives.tsx +143 -0
- package/src/components/reorder.ts +40 -0
- package/src/fieldComponents.ts +63 -0
- package/src/icons.ts +102 -0
- package/src/index.ts +198 -0
- package/src/media.ts +229 -0
- package/src/theme.ts +116 -0
- package/src/web/Button.tsx +110 -0
- package/src/web/Icon.tsx +52 -0
- package/src/web/Input.tsx +39 -0
- package/src/web/index.ts +43 -0
- package/src/web/primitives.tsx +119 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen, fireEvent, act } from "@testing-library/react";
|
|
3
|
+
import { ThemeProvider } from "../ThemeProvider";
|
|
4
|
+
import { ContentBrowser, type CmsEntry, type CmsNavSection, type ContentBrowserProps } from "../components/ContentBrowser";
|
|
5
|
+
import { MEDIA_NAV_KEY } from "../media";
|
|
6
|
+
import type { DocumentChange } from "../components/ChangeDetail";
|
|
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: "Changed content", items: [{ key: "posts", label: "Blog posts", icon: "newspaper", count: 2 }] },
|
|
18
|
+
{ title: "Changed configuration", items: [{ key: "site", label: "Site config", icon: "settings", count: 1 }] },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
// The changed-entry rows carry git status + line counts, which is what
|
|
22
|
+
// distinguishes this list from the editor's.
|
|
23
|
+
const entries: CmsEntry[] = [
|
|
24
|
+
{ id: "a", path: "content/blog/git-content.md", title: "Editing content in Git, explained", body: "", status: "M", added: 12, removed: 3 },
|
|
25
|
+
{ id: "b", path: "content/blog/files-not-database.md", title: "Your content is just files", body: "", status: "A", added: 48, removed: 0 },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const change: DocumentChange = {
|
|
29
|
+
path: "content/blog/git-content.md",
|
|
30
|
+
status: "M",
|
|
31
|
+
label: "Editing content in Git, explained",
|
|
32
|
+
added: 12,
|
|
33
|
+
removed: 3,
|
|
34
|
+
fields: [
|
|
35
|
+
{ name: "title", label: "Title", kind: "unchanged", before: "Editing content in Git, explained", after: "Editing content in Git, explained" },
|
|
36
|
+
{ name: "status", label: "Status", kind: "changed", before: "draft", after: "published" },
|
|
37
|
+
],
|
|
38
|
+
bodyBefore: "Draft intro — to be rewritten before publishing.\n",
|
|
39
|
+
bodyAfter: "# Editing content in Git, explained\n",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const base = {
|
|
43
|
+
workspace: { name: "acme/docs", initials: "AC", branch: "feat/rewrite", changed: 3 },
|
|
44
|
+
sections,
|
|
45
|
+
activeNavKey: "posts",
|
|
46
|
+
onSelectNav: () => {},
|
|
47
|
+
entries,
|
|
48
|
+
userInitials: "ED",
|
|
49
|
+
onSelectEntry: () => {},
|
|
50
|
+
surface: "changes" as const,
|
|
51
|
+
segments: [
|
|
52
|
+
{ key: "edit", label: "Edit" },
|
|
53
|
+
{ key: "changes", label: "Changes" },
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
test("the changes surface lists changed entries with their status and diff stat", () => {
|
|
58
|
+
render(wrap(<ContentBrowser {...base} />));
|
|
59
|
+
|
|
60
|
+
expect(screen.getByTestId("pane-changes")).toBeInTheDocument();
|
|
61
|
+
expect(screen.getByText("2 changed")).toBeInTheDocument();
|
|
62
|
+
|
|
63
|
+
// Status letters and line counts come from the entry rows the editor already
|
|
64
|
+
// knows how to render.
|
|
65
|
+
expect(screen.getByText("M")).toBeInTheDocument();
|
|
66
|
+
expect(screen.getByText("A")).toBeInTheDocument();
|
|
67
|
+
expect(screen.getByText("+12")).toBeInTheDocument();
|
|
68
|
+
expect(screen.getByText("-3")).toBeInTheDocument();
|
|
69
|
+
expect(screen.getByText("content/blog/git-content.md")).toBeInTheDocument();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a changed field renders before and after; an unchanged one renders as context", () => {
|
|
73
|
+
render(wrap(<ContentBrowser {...base} selectedChange={change} />));
|
|
74
|
+
|
|
75
|
+
expect(screen.getByTestId("change-detail")).toBeInTheDocument();
|
|
76
|
+
|
|
77
|
+
// The changed field shows both sides.
|
|
78
|
+
const status = screen.getByTestId("change-field-status");
|
|
79
|
+
expect(status).toHaveTextContent("draft");
|
|
80
|
+
expect(status).toHaveTextContent("published");
|
|
81
|
+
|
|
82
|
+
// The unchanged field shows its value once, with no diff tint.
|
|
83
|
+
const title = screen.getByTestId("change-field-title");
|
|
84
|
+
expect(title).toHaveTextContent("Editing content in Git, explained");
|
|
85
|
+
expect(title.querySelectorAll('[data-testid="diff-add"]')).toHaveLength(0);
|
|
86
|
+
expect(title.querySelectorAll('[data-testid="diff-del"]')).toHaveLength(0);
|
|
87
|
+
|
|
88
|
+
// A changed body is a diff too.
|
|
89
|
+
expect(screen.getByTestId("change-body")).toBeInTheDocument();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("the changes surface offers no editing affordances", () => {
|
|
93
|
+
render(
|
|
94
|
+
wrap(
|
|
95
|
+
<ContentBrowser
|
|
96
|
+
{...base}
|
|
97
|
+
selectedChange={change}
|
|
98
|
+
// Even with every CRUD handler wired, the changes surface must not
|
|
99
|
+
// surface them — it describes a comparison, not an editable document.
|
|
100
|
+
canCreate
|
|
101
|
+
onStartCreate={() => {}}
|
|
102
|
+
selectable
|
|
103
|
+
onBulkDelete={() => {}}
|
|
104
|
+
searchable
|
|
105
|
+
onSearchChange={() => {}}
|
|
106
|
+
onSaveEntry={() => {}}
|
|
107
|
+
/>,
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
expect(screen.queryByTestId("create-entry")).not.toBeInTheDocument();
|
|
112
|
+
expect(screen.queryByTestId("search-open")).not.toBeInTheDocument();
|
|
113
|
+
expect(screen.queryByTestId("detail-menu")).not.toBeInTheDocument();
|
|
114
|
+
expect(screen.queryByTestId("autosave-indicator")).not.toBeInTheDocument();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("the segment control switches surfaces and marks the active one", () => {
|
|
118
|
+
const onSelectSegment = jest.fn();
|
|
119
|
+
render(wrap(<ContentBrowser {...base} onSelectSegment={onSelectSegment} />));
|
|
120
|
+
|
|
121
|
+
const changes = screen.getByTestId("segment-changes");
|
|
122
|
+
expect(changes).toHaveAttribute("aria-selected", "true");
|
|
123
|
+
|
|
124
|
+
fireEvent.click(screen.getByTestId("segment-edit"));
|
|
125
|
+
expect(onSelectSegment).toHaveBeenCalledWith("edit");
|
|
126
|
+
|
|
127
|
+
// The active segment is inert — clicking it must not re-navigate.
|
|
128
|
+
onSelectSegment.mockClear();
|
|
129
|
+
fireEvent.click(changes);
|
|
130
|
+
expect(onSelectSegment).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("a disabled segment cannot be selected", () => {
|
|
134
|
+
const onSelectSegment = jest.fn();
|
|
135
|
+
render(
|
|
136
|
+
wrap(
|
|
137
|
+
<ContentBrowser
|
|
138
|
+
{...base}
|
|
139
|
+
surface="edit"
|
|
140
|
+
segments={[
|
|
141
|
+
{ key: "edit", label: "Edit" },
|
|
142
|
+
// On the default branch there is nothing to compare against.
|
|
143
|
+
{ key: "changes", label: "Changes", disabled: true },
|
|
144
|
+
]}
|
|
145
|
+
onSelectSegment={onSelectSegment}
|
|
146
|
+
/>,
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
fireEvent.click(screen.getByTestId("segment-changes"));
|
|
151
|
+
expect(onSelectSegment).not.toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("the branch control stays an inert badge until there is somewhere to switch to", () => {
|
|
155
|
+
const { rerender } = render(wrap(<ContentBrowser {...base} />));
|
|
156
|
+
expect(screen.getByTestId("branch-badge")).toBeInTheDocument();
|
|
157
|
+
expect(screen.queryByTestId("branch-menu")).not.toBeInTheDocument();
|
|
158
|
+
|
|
159
|
+
rerender(
|
|
160
|
+
wrap(
|
|
161
|
+
<ContentBrowser
|
|
162
|
+
{...base}
|
|
163
|
+
branches={[
|
|
164
|
+
{ id: "1", name: "main" },
|
|
165
|
+
{ id: "2", name: "feat/rewrite" },
|
|
166
|
+
]}
|
|
167
|
+
defaultBranch="main"
|
|
168
|
+
onSelectBranch={() => {}}
|
|
169
|
+
/>,
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
expect(screen.getByTestId("branch-menu")).toBeInTheDocument();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("the branch menu lists branches, marks the default, and reports a switch", () => {
|
|
176
|
+
const onSelectBranch = jest.fn();
|
|
177
|
+
render(
|
|
178
|
+
wrap(
|
|
179
|
+
<ContentBrowser
|
|
180
|
+
{...base}
|
|
181
|
+
branches={[
|
|
182
|
+
{ id: "1", name: "main" },
|
|
183
|
+
{ id: "2", name: "feat/rewrite" },
|
|
184
|
+
]}
|
|
185
|
+
defaultBranch="main"
|
|
186
|
+
onSelectBranch={onSelectBranch}
|
|
187
|
+
/>,
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
fireEvent.click(screen.getByTestId("branch-menu"));
|
|
192
|
+
expect(screen.getByTestId("branch-menu-list")).toBeInTheDocument();
|
|
193
|
+
expect(screen.getByText("default")).toBeInTheDocument();
|
|
194
|
+
|
|
195
|
+
fireEvent.click(screen.getByTestId("branch-option-1"));
|
|
196
|
+
expect(onSelectBranch).toHaveBeenCalledWith({ id: "1", name: "main" });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("the branch menu creates a branch from the current one", async () => {
|
|
200
|
+
const onCreateBranch = jest.fn().mockResolvedValue(undefined);
|
|
201
|
+
render(
|
|
202
|
+
wrap(
|
|
203
|
+
<ContentBrowser
|
|
204
|
+
{...base}
|
|
205
|
+
branches={[{ id: "2", name: "feat/rewrite" }]}
|
|
206
|
+
defaultBranch="main"
|
|
207
|
+
onCreateBranch={onCreateBranch}
|
|
208
|
+
/>,
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
fireEvent.click(screen.getByTestId("branch-menu"));
|
|
213
|
+
fireEvent.click(screen.getByTestId("branch-create"));
|
|
214
|
+
// The form says what the new branch is cut from, so the choice is not implicit.
|
|
215
|
+
expect(screen.getByText("Branched from feat/rewrite")).toBeInTheDocument();
|
|
216
|
+
|
|
217
|
+
fireEvent.change(screen.getByTestId("branch-create-input"), { target: { value: "feat/next" } });
|
|
218
|
+
// The submit handler awaits onCreate before closing the form, so the close is
|
|
219
|
+
// a state update outside the click's act() scope unless it is flushed here.
|
|
220
|
+
await act(async () => {
|
|
221
|
+
fireEvent.click(screen.getByTestId("branch-create-submit"));
|
|
222
|
+
});
|
|
223
|
+
expect(onCreateBranch).toHaveBeenCalledWith("feat/next");
|
|
224
|
+
expect(screen.queryByTestId("branch-create-input")).not.toBeInTheDocument();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// The editor derives its Media nav row from the media api — every set, always.
|
|
228
|
+
// That derivation is wrong on the changes surface, where the row must reflect
|
|
229
|
+
// what actually CHANGED. So the changes nav shows Media only when the host puts
|
|
230
|
+
// it in `sections`, driven by the media change set.
|
|
231
|
+
test("the changes nav does not derive Media from the media api", () => {
|
|
232
|
+
const media = {
|
|
233
|
+
sets: [{ name: "images", label: "Images", count: 12 }],
|
|
234
|
+
} as unknown as ContentBrowserProps["media"];
|
|
235
|
+
|
|
236
|
+
const { rerender } = render(wrap(<ContentBrowser {...base} surface="edit" media={media} />));
|
|
237
|
+
// The editor derives it: all 12 files, regardless of any comparison.
|
|
238
|
+
expect(screen.getByTestId(`nav-${MEDIA_NAV_KEY}`)).toBeInTheDocument();
|
|
239
|
+
|
|
240
|
+
// Same media api, changes surface, no media section supplied → no row, because
|
|
241
|
+
// nothing said any asset changed.
|
|
242
|
+
rerender(wrap(<ContentBrowser {...base} surface="changes" media={media} />));
|
|
243
|
+
expect(screen.queryByTestId(`nav-${MEDIA_NAV_KEY}`)).not.toBeInTheDocument();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("the changes nav shows Media when the host reports changed assets", () => {
|
|
247
|
+
const media = {
|
|
248
|
+
sets: [{ name: "images", label: "Images", count: 12 }],
|
|
249
|
+
} as unknown as ContentBrowserProps["media"];
|
|
250
|
+
// What the host builds from branchMediaChanges: 2 changed assets, not 12 files.
|
|
251
|
+
const withMedia: CmsNavSection[] = [
|
|
252
|
+
...sections,
|
|
253
|
+
{ title: "", items: [{ key: MEDIA_NAV_KEY, label: "Media", icon: "image", count: 2 }] },
|
|
254
|
+
];
|
|
255
|
+
|
|
256
|
+
render(wrap(<ContentBrowser {...base} surface="changes" sections={withMedia} media={media} />));
|
|
257
|
+
|
|
258
|
+
const row = screen.getByTestId(`nav-${MEDIA_NAV_KEY}`);
|
|
259
|
+
expect(row).toBeInTheDocument();
|
|
260
|
+
expect(row).toHaveTextContent("2");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Selecting Media on the changes surface lists the changed assets; it must not
|
|
264
|
+
// hand over to the media browser, which shows every file and has no diff.
|
|
265
|
+
test("the changes surface lists changed assets rather than opening the media browser", () => {
|
|
266
|
+
const media = {
|
|
267
|
+
sets: [{ name: "images", label: "Images", count: 12 }],
|
|
268
|
+
} as unknown as ContentBrowserProps["media"];
|
|
269
|
+
const assets: CmsEntry[] = [
|
|
270
|
+
{ id: "public/images/logo.png", path: "public/images/logo.png", title: "logo.png", body: "", status: "M" },
|
|
271
|
+
{ id: "public/images/new.png", path: "public/images/new.png", title: "new.png", body: "", status: "A" },
|
|
272
|
+
];
|
|
273
|
+
|
|
274
|
+
render(
|
|
275
|
+
wrap(
|
|
276
|
+
<ContentBrowser
|
|
277
|
+
{...base}
|
|
278
|
+
surface="changes"
|
|
279
|
+
activeNavKey={MEDIA_NAV_KEY}
|
|
280
|
+
sections={[...sections, { title: "", items: [{ key: MEDIA_NAV_KEY, label: "Media", icon: "image", count: 2 }] }]}
|
|
281
|
+
entries={assets}
|
|
282
|
+
media={media}
|
|
283
|
+
/>,
|
|
284
|
+
),
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
expect(screen.getByTestId("pane-changes")).toBeInTheDocument();
|
|
288
|
+
expect(screen.queryByTestId("pane-media-sets")).not.toBeInTheDocument();
|
|
289
|
+
expect(screen.getByText("public/images/logo.png")).toBeInTheDocument();
|
|
290
|
+
expect(screen.getByText("2 changed")).toBeInTheDocument();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("the edit surface is unchanged when no surface is given", () => {
|
|
294
|
+
render(
|
|
295
|
+
wrap(
|
|
296
|
+
<ContentBrowser
|
|
297
|
+
workspace={base.workspace}
|
|
298
|
+
sections={sections}
|
|
299
|
+
activeNavKey="posts"
|
|
300
|
+
onSelectNav={() => {}}
|
|
301
|
+
entries={entries}
|
|
302
|
+
userInitials="ED"
|
|
303
|
+
entriesEmpty="Choose a collection"
|
|
304
|
+
onSelectEntry={() => {}}
|
|
305
|
+
/>,
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
// No surface prop → the editor panes, not the changes ones.
|
|
310
|
+
expect(screen.getByTestId("pane-entries")).toBeInTheDocument();
|
|
311
|
+
expect(screen.queryByTestId("pane-changes")).not.toBeInTheDocument();
|
|
312
|
+
expect(screen.queryByTestId("surface-segment")).not.toBeInTheDocument();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// The segment must be centered on the BAR, not in the space left over between
|
|
316
|
+
// the left and right slots — those are never equal widths, so a flex-sibling
|
|
317
|
+
// center sits visibly off-center. jsdom computes no layout, so this asserts the
|
|
318
|
+
// mechanism (an overlay spanning the bar) rather than measured pixels.
|
|
319
|
+
test("the segment control is centered against the bar, not between its neighbours", () => {
|
|
320
|
+
render(wrap(<ContentBrowser {...base} />));
|
|
321
|
+
|
|
322
|
+
const segment = screen.getByTestId("surface-segment");
|
|
323
|
+
const inner = segment.parentElement!;
|
|
324
|
+
const overlay = inner.parentElement!;
|
|
325
|
+
expect(overlay).toHaveStyle({ position: "absolute", left: "0px", right: "0px" });
|
|
326
|
+
expect(overlay).toHaveStyle({ justifyContent: "center" });
|
|
327
|
+
|
|
328
|
+
// The overlay spans the whole bar, so it must not intercept clicks aimed at
|
|
329
|
+
// the branch menu, bell or sign-out underneath it — while the segment itself
|
|
330
|
+
// stays clickable. Asserted as real CSS values: react-native's `box-none`
|
|
331
|
+
// would say this in one place, but react-native-web emits it as an invalid
|
|
332
|
+
// CSS value that browsers discard, silently reverting the overlay to `auto`.
|
|
333
|
+
expect(overlay).toHaveStyle({ pointerEvents: "none" });
|
|
334
|
+
expect(inner).toHaveStyle({ pointerEvents: "auto" });
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// A changed or added asset is worth showing; a deleted one is not — there is no
|
|
338
|
+
// current version, and previewing the base branch's copy would show a file this
|
|
339
|
+
// branch removed as though it were still there.
|
|
340
|
+
const mediaChange = (over: Partial<DocumentChange>): DocumentChange => ({
|
|
341
|
+
path: "public/images/logo.png",
|
|
342
|
+
status: "A",
|
|
343
|
+
label: "logo.png",
|
|
344
|
+
added: 0,
|
|
345
|
+
removed: 0,
|
|
346
|
+
fields: [{ name: "type", label: "Type", kind: "added", after: "image/png" }],
|
|
347
|
+
...over,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("an added asset previews only the new version", () => {
|
|
351
|
+
render(
|
|
352
|
+
wrap(
|
|
353
|
+
<ContentBrowser
|
|
354
|
+
{...base}
|
|
355
|
+
selectedChange={mediaChange({
|
|
356
|
+
status: "A",
|
|
357
|
+
preview: { kind: "image", afterUrl: "https://store/new.png" },
|
|
358
|
+
})}
|
|
359
|
+
/>,
|
|
360
|
+
),
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
expect(screen.getByTestId("change-preview")).toBeInTheDocument();
|
|
364
|
+
expect(screen.getByTestId("preview-add")).toBeInTheDocument();
|
|
365
|
+
expect(screen.queryByTestId("preview-del")).not.toBeInTheDocument();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("a modified asset previews both versions", () => {
|
|
369
|
+
render(
|
|
370
|
+
wrap(
|
|
371
|
+
<ContentBrowser
|
|
372
|
+
{...base}
|
|
373
|
+
selectedChange={mediaChange({
|
|
374
|
+
status: "M",
|
|
375
|
+
preview: { kind: "image", beforeUrl: "https://store/old.png", afterUrl: "https://store/new.png" },
|
|
376
|
+
})}
|
|
377
|
+
/>,
|
|
378
|
+
),
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
expect(screen.getByTestId("preview-del")).toBeInTheDocument();
|
|
382
|
+
expect(screen.getByTestId("preview-add")).toBeInTheDocument();
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("a deleted asset is not previewed", () => {
|
|
386
|
+
render(
|
|
387
|
+
wrap(
|
|
388
|
+
<ContentBrowser
|
|
389
|
+
{...base}
|
|
390
|
+
selectedChange={mediaChange({
|
|
391
|
+
status: "D",
|
|
392
|
+
// The base branch's copy is still addressable, but this branch removed
|
|
393
|
+
// the file — there is no current version to show.
|
|
394
|
+
preview: { kind: "image", beforeUrl: "https://store/old.png", afterUrl: null },
|
|
395
|
+
})}
|
|
396
|
+
/>,
|
|
397
|
+
),
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
expect(screen.queryByTestId("change-preview")).not.toBeInTheDocument();
|
|
401
|
+
expect(screen.queryByTestId("preview-del")).not.toBeInTheDocument();
|
|
402
|
+
// The metadata still renders — only the picture is withheld.
|
|
403
|
+
expect(screen.getByTestId("change-field-type")).toBeInTheDocument();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("a non-visual asset falls back to metadata rather than a broken frame", () => {
|
|
407
|
+
render(
|
|
408
|
+
wrap(
|
|
409
|
+
<ContentBrowser
|
|
410
|
+
{...base}
|
|
411
|
+
selectedChange={mediaChange({
|
|
412
|
+
status: "A",
|
|
413
|
+
preview: { kind: "pdf", afterUrl: "https://store/doc.pdf" },
|
|
414
|
+
})}
|
|
415
|
+
/>,
|
|
416
|
+
),
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
expect(screen.queryByTestId("change-preview")).not.toBeInTheDocument();
|
|
420
|
+
expect(screen.getByTestId("change-field-type")).toBeInTheDocument();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("the changes top bar shows a source → target selector; edit shows a single branch menu", () => {
|
|
424
|
+
const branchProps = {
|
|
425
|
+
branches: [
|
|
426
|
+
{ id: "1", name: "main" },
|
|
427
|
+
{ id: "2", name: "feat/rewrite" },
|
|
428
|
+
{ id: "3", name: "draft/spring" },
|
|
429
|
+
],
|
|
430
|
+
defaultBranch: "main",
|
|
431
|
+
onSelectBranch: () => {},
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
// Edit surface: single branch menu, no target.
|
|
435
|
+
const { rerender } = render(
|
|
436
|
+
wrap(<ContentBrowser {...base} surface="edit" {...branchProps} />),
|
|
437
|
+
);
|
|
438
|
+
expect(screen.getByTestId("branch-menu")).toBeInTheDocument();
|
|
439
|
+
expect(screen.queryByTestId("target-branch-menu")).not.toBeInTheDocument();
|
|
440
|
+
|
|
441
|
+
// Changes surface with a target: source (branch-menu) → target (target-branch-menu).
|
|
442
|
+
rerender(
|
|
443
|
+
wrap(
|
|
444
|
+
<ContentBrowser
|
|
445
|
+
{...base}
|
|
446
|
+
surface="changes"
|
|
447
|
+
{...branchProps}
|
|
448
|
+
targetBranch="main"
|
|
449
|
+
onSelectTarget={() => {}}
|
|
450
|
+
/>,
|
|
451
|
+
),
|
|
452
|
+
);
|
|
453
|
+
expect(screen.getByTestId("branch-menu")).toBeInTheDocument();
|
|
454
|
+
expect(screen.getByTestId("target-branch-menu")).toBeInTheDocument();
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("the target menu excludes the source branch and reports a retarget", () => {
|
|
458
|
+
const onSelectTarget = jest.fn();
|
|
459
|
+
render(
|
|
460
|
+
wrap(
|
|
461
|
+
<ContentBrowser
|
|
462
|
+
{...base}
|
|
463
|
+
surface="changes"
|
|
464
|
+
// The source under review is workspace.branch = "feat/rewrite".
|
|
465
|
+
branches={[
|
|
466
|
+
{ id: "1", name: "main" },
|
|
467
|
+
{ id: "2", name: "feat/rewrite" },
|
|
468
|
+
{ id: "3", name: "draft/spring" },
|
|
469
|
+
]}
|
|
470
|
+
defaultBranch="main"
|
|
471
|
+
targetBranch="main"
|
|
472
|
+
onSelectTarget={onSelectTarget}
|
|
473
|
+
/>,
|
|
474
|
+
),
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
fireEvent.click(screen.getByTestId("target-branch-menu"));
|
|
478
|
+
// The source can't be its own target, so it's absent from the target options.
|
|
479
|
+
expect(screen.queryByTestId("target-branch-option-2")).not.toBeInTheDocument();
|
|
480
|
+
expect(screen.getByTestId("target-branch-option-3")).toBeInTheDocument();
|
|
481
|
+
|
|
482
|
+
fireEvent.click(screen.getByTestId("target-branch-option-3"));
|
|
483
|
+
expect(onSelectTarget).toHaveBeenCalledWith({ id: "3", name: "draft/spring" });
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("the Apply button appears on the changes surface, gated by canApplyChanges", () => {
|
|
487
|
+
const onApplyChanges = jest.fn();
|
|
488
|
+
const { rerender } = render(
|
|
489
|
+
wrap(<ContentBrowser {...base} onApplyChanges={onApplyChanges} canApplyChanges={false} />),
|
|
490
|
+
);
|
|
491
|
+
// Present but disabled when there's nothing to apply.
|
|
492
|
+
const btn = screen.getByTestId("apply-changes");
|
|
493
|
+
expect(btn).toBeInTheDocument();
|
|
494
|
+
fireEvent.click(btn);
|
|
495
|
+
expect(onApplyChanges).not.toHaveBeenCalled();
|
|
496
|
+
|
|
497
|
+
rerender(wrap(<ContentBrowser {...base} onApplyChanges={onApplyChanges} canApplyChanges />));
|
|
498
|
+
fireEvent.click(screen.getByTestId("apply-changes"));
|
|
499
|
+
expect(onApplyChanges).toHaveBeenCalledTimes(1);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test("the apply modal opens on the confirmation step, then shows progress", () => {
|
|
503
|
+
const { rerender } = render(
|
|
504
|
+
wrap(
|
|
505
|
+
<ContentBrowser
|
|
506
|
+
{...base}
|
|
507
|
+
targetBranch="main"
|
|
508
|
+
onApplyChanges={() => {}}
|
|
509
|
+
applyOpen
|
|
510
|
+
onConfirmApply={() => {}}
|
|
511
|
+
onCloseApply={() => {}}
|
|
512
|
+
/>,
|
|
513
|
+
),
|
|
514
|
+
);
|
|
515
|
+
// Open with no run yet → the confirmation step.
|
|
516
|
+
expect(screen.getByTestId("apply-changes-modal")).toBeInTheDocument();
|
|
517
|
+
expect(screen.getByTestId("apply-confirm")).toBeInTheDocument();
|
|
518
|
+
|
|
519
|
+
// Once a run exists → the progress checklist.
|
|
520
|
+
rerender(
|
|
521
|
+
wrap(
|
|
522
|
+
<ContentBrowser
|
|
523
|
+
{...base}
|
|
524
|
+
targetBranch="main"
|
|
525
|
+
onApplyChanges={() => {}}
|
|
526
|
+
applyOpen
|
|
527
|
+
applyProgress={{ status: "running", step: "comparing", sourceBranch: "feat/rewrite", targetBranch: "main" }}
|
|
528
|
+
onCloseApply={() => {}}
|
|
529
|
+
/>,
|
|
530
|
+
),
|
|
531
|
+
);
|
|
532
|
+
expect(screen.queryByTestId("apply-confirm")).not.toBeInTheDocument();
|
|
533
|
+
expect(screen.getByTestId("merge-step-active")).toBeInTheDocument();
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("the edit surface shows no Apply button", () => {
|
|
537
|
+
render(wrap(<ContentBrowser {...base} surface="edit" onApplyChanges={() => {}} canApplyChanges />));
|
|
538
|
+
// The Apply button lives in the changes top bar; the edit top bar omits it.
|
|
539
|
+
expect(screen.queryByTestId("apply-changes")).not.toBeInTheDocument();
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("a conflicted field renders the resolution controls and reports a choice", () => {
|
|
543
|
+
const onResolveConflict = jest.fn();
|
|
544
|
+
render(
|
|
545
|
+
wrap(
|
|
546
|
+
<ContentBrowser
|
|
547
|
+
{...base}
|
|
548
|
+
selectedChange={change}
|
|
549
|
+
selectedConflicts={[
|
|
550
|
+
{ id: "c1", field: "status", isBody: false, before: "draft", after: "published", resolution: null },
|
|
551
|
+
]}
|
|
552
|
+
onResolveConflict={onResolveConflict}
|
|
553
|
+
/>,
|
|
554
|
+
),
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
// The conflicted field shows both candidate values under the red treatment,
|
|
558
|
+
// with three resolution choices.
|
|
559
|
+
const row = screen.getByTestId("conflict-field-status");
|
|
560
|
+
// Both candidate values are shown (before = target/HEAD, after = source).
|
|
561
|
+
expect(row).toHaveTextContent("before");
|
|
562
|
+
expect(row).toHaveTextContent("after");
|
|
563
|
+
expect(row).toHaveTextContent("draft");
|
|
564
|
+
expect(row).toHaveTextContent("published");
|
|
565
|
+
expect(screen.getByTestId("conflict-c1-ours")).toBeInTheDocument();
|
|
566
|
+
expect(screen.getByTestId("conflict-c1-theirs")).toBeInTheDocument();
|
|
567
|
+
expect(screen.getByTestId("conflict-c1-override")).toBeInTheDocument();
|
|
568
|
+
|
|
569
|
+
fireEvent.click(screen.getByTestId("conflict-c1-theirs"));
|
|
570
|
+
expect(onResolveConflict).toHaveBeenCalledWith(
|
|
571
|
+
expect.objectContaining({ id: "c1" }),
|
|
572
|
+
"theirs",
|
|
573
|
+
undefined,
|
|
574
|
+
);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("override reveals an input and saves the entered value", () => {
|
|
578
|
+
const onResolveConflict = jest.fn();
|
|
579
|
+
render(
|
|
580
|
+
wrap(
|
|
581
|
+
<ContentBrowser
|
|
582
|
+
{...base}
|
|
583
|
+
selectedChange={change}
|
|
584
|
+
selectedConflicts={[{ id: "c1", field: "status", isBody: false, before: "draft", after: "published" }]}
|
|
585
|
+
onResolveConflict={onResolveConflict}
|
|
586
|
+
/>,
|
|
587
|
+
),
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
fireEvent.click(screen.getByTestId("conflict-c1-override"));
|
|
591
|
+
fireEvent.change(screen.getByTestId("conflict-c1-override-input"), { target: { value: "archived" } });
|
|
592
|
+
fireEvent.click(screen.getByTestId("conflict-c1-override-save"));
|
|
593
|
+
expect(onResolveConflict).toHaveBeenCalledWith(expect.objectContaining({ id: "c1" }), "override", "archived");
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("the sidebar shows a conflict count and the list highlights conflicted rows", () => {
|
|
597
|
+
const withConflicts: CmsNavSection[] = [
|
|
598
|
+
{ title: "Changed content", items: [{ key: "posts", label: "Blog posts", icon: "newspaper", count: 2, conflicts: 1 }] },
|
|
599
|
+
];
|
|
600
|
+
const conflictedEntries: CmsEntry[] = [
|
|
601
|
+
{ id: "a", path: "content/blog/git-content.md", title: "Editing content in Git", body: "", status: "M", conflicted: true },
|
|
602
|
+
{ id: "b", path: "content/blog/other.md", title: "Other", body: "", status: "M" },
|
|
603
|
+
];
|
|
604
|
+
render(wrap(<ContentBrowser {...base} sections={withConflicts} entries={conflictedEntries} />));
|
|
605
|
+
|
|
606
|
+
// Sidebar: a red conflict pill on the collection.
|
|
607
|
+
expect(screen.getByTestId("nav-posts-conflicts")).toHaveTextContent("1");
|
|
608
|
+
// List: the conflicted row carries a Conflict badge; the clean one doesn't.
|
|
609
|
+
const badges = screen.getAllByText("Conflict");
|
|
610
|
+
expect(badges.length).toBe(1);
|
|
611
|
+
});
|