@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.
Files changed (70) hide show
  1. package/README.md +77 -0
  2. package/css/components.css +312 -0
  3. package/css/tokens.css +212 -0
  4. package/package.json +65 -0
  5. package/src/ThemeProvider.tsx +86 -0
  6. package/src/__tests__/ApplyChangesModal.test.tsx +148 -0
  7. package/src/__tests__/BranchImport.test.tsx +46 -0
  8. package/src/__tests__/Button.test.tsx +45 -0
  9. package/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
  10. package/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
  11. package/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
  12. package/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
  13. package/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
  14. package/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
  15. package/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
  16. package/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
  17. package/src/__tests__/ContentBrowser.media.test.tsx +140 -0
  18. package/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
  19. package/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
  20. package/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
  21. package/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
  22. package/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
  23. package/src/__tests__/ContentBrowser.search.test.tsx +135 -0
  24. package/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
  25. package/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
  26. package/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
  27. package/src/__tests__/MediaBrowser.test.tsx +353 -0
  28. package/src/__tests__/MediaField.test.tsx +185 -0
  29. package/src/__tests__/Notifications.test.tsx +69 -0
  30. package/src/__tests__/Onboarding.test.tsx +287 -0
  31. package/src/__tests__/cssTokens.test.ts +201 -0
  32. package/src/__tests__/fieldComponents.test.ts +43 -0
  33. package/src/__tests__/reorder.test.ts +58 -0
  34. package/src/components/ApplyChangesModal.tsx +348 -0
  35. package/src/components/BranchImport.tsx +157 -0
  36. package/src/components/BranchMenu.tsx +192 -0
  37. package/src/components/Button.tsx +131 -0
  38. package/src/components/ChangeDetail.tsx +472 -0
  39. package/src/components/ChangeRequestSummary.tsx +173 -0
  40. package/src/components/CollabField.tsx +388 -0
  41. package/src/components/ContentBrowser.tsx +5073 -0
  42. package/src/components/Icon.tsx +28 -0
  43. package/src/components/Icon.web.tsx +31 -0
  44. package/src/components/Input.tsx +106 -0
  45. package/src/components/MediaBrowser.tsx +766 -0
  46. package/src/components/MediaField.tsx +670 -0
  47. package/src/components/MediaPreview.tsx +91 -0
  48. package/src/components/MediaPreview.web.tsx +169 -0
  49. package/src/components/NavRow.tsx +105 -0
  50. package/src/components/Notifications.tsx +301 -0
  51. package/src/components/Onboarding.tsx +751 -0
  52. package/src/components/ProjectMenu.tsx +124 -0
  53. package/src/components/Segment.tsx +87 -0
  54. package/src/components/Skeleton.tsx +216 -0
  55. package/src/components/Spinner.tsx +44 -0
  56. package/src/components/Text.tsx +85 -0
  57. package/src/components/documentDrafts.ts +213 -0
  58. package/src/components/layout.tsx +284 -0
  59. package/src/components/primitives.tsx +143 -0
  60. package/src/components/reorder.ts +40 -0
  61. package/src/fieldComponents.ts +63 -0
  62. package/src/icons.ts +102 -0
  63. package/src/index.ts +198 -0
  64. package/src/media.ts +229 -0
  65. package/src/theme.ts +116 -0
  66. package/src/web/Button.tsx +110 -0
  67. package/src/web/Icon.tsx +52 -0
  68. package/src/web/Input.tsx +39 -0
  69. package/src/web/index.ts +43 -0
  70. 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
+ });