@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,353 @@
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 { MediaBrowser, conditionsFrom } from "../components/MediaBrowser";
6
+ import { MediaProvider } from "../components/MediaField";
7
+ import { MEDIA_NAV_KEY, mediaNavKey, isMediaNavKey, parseMediaNavKey } from "../media";
8
+ import type { MediaApi, MediaAsset, MediaFacets } from "../media";
9
+
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
+ function asset(over: Partial<MediaAsset> = {}): MediaAsset {
16
+ return {
17
+ id: "m1",
18
+ set: "uploads",
19
+ repoPath: "public/uploads/hero.png",
20
+ publicPath: "/uploads/hero.png",
21
+ cdnPath: null,
22
+ mimeType: "image/png",
23
+ extension: "png",
24
+ kind: "image",
25
+ size: 2048,
26
+ width: 800,
27
+ height: 600,
28
+ pending: false,
29
+ url: "https://store.example.com/signed/hero.png",
30
+ ...over,
31
+ };
32
+ }
33
+
34
+ const facets: MediaFacets = {
35
+ extensions: [{ value: "png", count: 2 }, { value: "pdf", count: 1 }],
36
+ mimeTypes: [{ value: "image/png", count: 2 }, { value: "application/pdf", count: 1 }],
37
+ kinds: [{ value: "image", count: 2 }, { value: "pdf", count: 1 }],
38
+ minSize: 9,
39
+ maxSize: 4096,
40
+ total: 3,
41
+ };
42
+
43
+ function makeApi(over: Partial<MediaApi> = {}): MediaApi {
44
+ return {
45
+ sets: [
46
+ { name: "uploads", path: "public/uploads/**/*", mimeTypes: [], count: 3 },
47
+ { name: "documents", path: "public/docs/**/*.pdf", mimeTypes: [], count: 1 },
48
+ ],
49
+ resolve: jest.fn(async () => []),
50
+ byId: jest.fn(async () => null),
51
+ list: jest.fn(async () => [asset(), asset({ id: "m2", repoPath: "files/manual.pdf", kind: "pdf", extension: "pdf", mimeType: "application/pdf" })]),
52
+ count: jest.fn(async () => 2),
53
+ facets: jest.fn(async () => facets),
54
+ ...over,
55
+ };
56
+ }
57
+
58
+ function renderBrowser(api: MediaApi | undefined, set = "uploads") {
59
+ render(
60
+ <ThemeProvider>
61
+ <MediaProvider api={api}>
62
+ <MediaBrowser set={set} label="Uploads" />
63
+ </MediaProvider>
64
+ </ThemeProvider>,
65
+ );
66
+ }
67
+
68
+ describe("nav keys", () => {
69
+ test("the button and a set are both media, but only one names a set", () => {
70
+ expect(mediaNavKey()).toBe(MEDIA_NAV_KEY);
71
+ expect(mediaNavKey("uploads")).toBe("media:uploads");
72
+
73
+ // The bare button is media with nothing selected yet.
74
+ expect(isMediaNavKey(MEDIA_NAV_KEY)).toBe(true);
75
+ expect(parseMediaNavKey(MEDIA_NAV_KEY)).toBeNull();
76
+
77
+ // A set is media, and names itself.
78
+ expect(isMediaNavKey("media:uploads")).toBe(true);
79
+ expect(parseMediaNavKey("media:uploads")).toBe("uploads");
80
+ });
81
+
82
+ test("collections are never mistaken for media", () => {
83
+ for (const key of ["posts", "", null, undefined]) {
84
+ expect(isMediaNavKey(key)).toBe(false);
85
+ expect(parseMediaNavKey(key)).toBeNull();
86
+ }
87
+ // A collection may legitimately be *named* "media"; the colon is what
88
+ // separates the two spaces, and the config schema forbids it in a name.
89
+ expect(isMediaNavKey("media")).toBe(false);
90
+ expect(parseMediaNavKey("media")).toBeNull();
91
+ });
92
+ });
93
+
94
+ describe("sidebar section", () => {
95
+ const sections: CmsNavSection[] = [
96
+ { title: "Content", items: [{ key: "posts", label: "Posts", icon: "newspaper" }] },
97
+ ];
98
+ const entry: CmsEntry = { id: "a", path: "posts/a.md", title: "Alpha", body: "", fields: [] };
99
+
100
+ function renderShell(api: MediaApi | undefined, activeNavKey = "posts") {
101
+ const onSelectNav = jest.fn();
102
+ render(
103
+ <ThemeProvider>
104
+ <ContentBrowser
105
+ workspace={{ name: "acme/site", initials: "AC", branch: "main", changed: 0 }}
106
+ sections={sections}
107
+ activeNavKey={activeNavKey}
108
+ onSelectNav={onSelectNav}
109
+ entries={[entry]}
110
+ userInitials="ED"
111
+ media={api}
112
+ />
113
+ </ThemeProvider>,
114
+ );
115
+ return onSelectNav;
116
+ }
117
+
118
+ test("one Media button appears, not a row per set", () => {
119
+ renderShell(makeApi());
120
+ expect(screen.getByTestId("nav-media:")).toBeInTheDocument();
121
+ // The sets are the content list you get after pressing it, not sidebar rows.
122
+ expect(screen.queryByTestId("nav-media:uploads")).not.toBeInTheDocument();
123
+ expect(screen.queryByTestId("nav-media:documents")).not.toBeInTheDocument();
124
+ });
125
+
126
+ test("the button counts every file across the sets", () => {
127
+ renderShell(makeApi());
128
+ // 3 uploads + 1 document.
129
+ expect(screen.getByTestId("nav-media:")).toHaveTextContent("4");
130
+ });
131
+
132
+ // The requirement: no media collections, no button.
133
+ test("the button is hidden when there are no media sets", () => {
134
+ renderShell(makeApi({ sets: [] }));
135
+ expect(screen.queryByTestId("nav-media:")).not.toBeInTheDocument();
136
+ });
137
+
138
+ test("the button is hidden when the host wires no media api at all", () => {
139
+ renderShell(undefined);
140
+ expect(screen.queryByTestId("nav-media:")).not.toBeInTheDocument();
141
+ // The ordinary collections are untouched.
142
+ expect(screen.getByTestId("nav-posts")).toBeInTheDocument();
143
+ });
144
+
145
+ test("pressing Media reports the bare media key", () => {
146
+ const onSelectNav = renderShell(makeApi());
147
+ fireEvent.click(screen.getByTestId("nav-media:"));
148
+ expect(onSelectNav).toHaveBeenCalledWith("media:");
149
+ });
150
+
151
+ test("Media lists the collections in the content pane, details prompts for one", () => {
152
+ renderShell(makeApi(), "media:");
153
+ expect(screen.getByTestId("media-set-list")).toBeInTheDocument();
154
+ expect(screen.getByTestId("media-set-uploads")).toBeInTheDocument();
155
+ expect(screen.getByTestId("media-set-documents")).toBeInTheDocument();
156
+ // Nothing chosen yet, so the details pane says so rather than showing a
157
+ // browser for an arbitrary set.
158
+ 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
+ });
162
+
163
+ test("choosing a collection reports its key to the host", () => {
164
+ const onSelectNav = renderShell(makeApi(), "media:");
165
+ fireEvent.click(screen.getByTestId("media-set-uploads"));
166
+ expect(onSelectNav).toHaveBeenCalledWith("media:uploads");
167
+ });
168
+
169
+ test("a chosen collection shows its browser in the details pane, list still visible", async () => {
170
+ renderShell(makeApi(), "media:uploads");
171
+ // The content pane keeps listing the collections so you can switch.
172
+ expect(screen.getByTestId("media-set-list")).toBeInTheDocument();
173
+ expect(await screen.findByTestId("media-grid")).toBeInTheDocument();
174
+ expect(screen.queryByTestId("media-sets-empty")).not.toBeInTheDocument();
175
+ });
176
+
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
+ 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
+ });
185
+ });
186
+
187
+ describe("browser", () => {
188
+ test("lists the set's assets and reports the count", async () => {
189
+ const api = makeApi();
190
+ renderBrowser(api);
191
+ expect(await screen.findByTestId("media-tile-m1")).toBeInTheDocument();
192
+ expect(screen.getByTestId("media-tile-m2")).toBeInTheDocument();
193
+ expect(api.list).toHaveBeenCalledWith(expect.objectContaining({ set: "uploads", limit: 60, offset: 0 }));
194
+ await waitFor(() => expect(screen.getByTestId("media-count")).toHaveTextContent("2 files"));
195
+ });
196
+
197
+ test("toggles between grid and list", async () => {
198
+ renderBrowser(makeApi());
199
+ expect(await screen.findByTestId("media-grid")).toBeInTheDocument();
200
+
201
+ fireEvent.click(screen.getByTestId("media-view-list"));
202
+ expect(await screen.findByTestId("media-list")).toBeInTheDocument();
203
+ expect(screen.queryByTestId("media-grid")).not.toBeInTheDocument();
204
+
205
+ fireEvent.click(screen.getByTestId("media-view-grid"));
206
+ expect(await screen.findByTestId("media-grid")).toBeInTheDocument();
207
+ });
208
+
209
+ test("searching re-queries with the term", async () => {
210
+ const api = makeApi();
211
+ renderBrowser(api);
212
+ await screen.findByTestId("media-grid");
213
+
214
+ fireEvent.change(screen.getByTestId("media-search"), { target: { value: "hero" } });
215
+ await waitFor(
216
+ () => expect(api.list).toHaveBeenCalledWith(expect.objectContaining({ search: "hero" })),
217
+ { timeout: 2000 },
218
+ );
219
+ });
220
+
221
+ test("the facet panel offers the values the set actually holds, with counts", async () => {
222
+ renderBrowser(makeApi());
223
+ await screen.findByTestId("media-grid");
224
+
225
+ fireEvent.click(screen.getByTestId("media-filter-toggle"));
226
+ expect(await screen.findByTestId("media-facets")).toBeInTheDocument();
227
+ expect(screen.getByTestId("facet-ext-png")).toHaveTextContent("png (2)");
228
+ expect(screen.getByTestId("facet-mime-image/png")).toHaveTextContent("image/png (2)");
229
+ expect(screen.getByTestId("facet-kind-image")).toHaveTextContent("image (2)");
230
+ });
231
+
232
+ // Selecting a facet must not re-query until Apply — otherwise the list churns
233
+ // under the user while they are still choosing.
234
+ test("facets apply as filters only when applied", async () => {
235
+ const api = makeApi();
236
+ renderBrowser(api);
237
+ await screen.findByTestId("media-grid");
238
+ fireEvent.click(screen.getByTestId("media-filter-toggle"));
239
+ await screen.findByTestId("media-facets");
240
+
241
+ const before = (api.list as jest.Mock).mock.calls.length;
242
+ fireEvent.click(screen.getByTestId("facet-ext-png"));
243
+ expect((api.list as jest.Mock).mock.calls.length).toBe(before);
244
+
245
+ fireEvent.click(screen.getByTestId("media-facets-apply"));
246
+ await waitFor(
247
+ () =>
248
+ expect(api.list).toHaveBeenCalledWith(
249
+ expect.objectContaining({
250
+ filters: [{ field: "extension", op: "in", values: ["png"] }],
251
+ }),
252
+ ),
253
+ { timeout: 2000 },
254
+ );
255
+ });
256
+
257
+ test("without a facets provider the filter toggle is not offered", async () => {
258
+ renderBrowser(makeApi({ facets: undefined }));
259
+ await screen.findByTestId("media-grid");
260
+ expect(screen.queryByTestId("media-filter-toggle")).not.toBeInTheDocument();
261
+ // Search still works — the panel is an enhancement, not a requirement.
262
+ expect(screen.getByTestId("media-search")).toBeInTheDocument();
263
+ });
264
+
265
+ test("selecting an asset opens its details", async () => {
266
+ renderBrowser(makeApi());
267
+ fireEvent.click(await screen.findByTestId("media-tile-m1"));
268
+
269
+ expect(await screen.findByText("public/uploads/hero.png")).toBeInTheDocument();
270
+ expect(screen.getByText("800 × 600")).toBeInTheDocument();
271
+
272
+ fireEvent.click(screen.getByTestId("media-detail-close"));
273
+ await waitFor(() => expect(screen.queryByTestId("media-detail-close")).not.toBeInTheDocument());
274
+ });
275
+
276
+ test("an empty set says so rather than showing a blank pane", async () => {
277
+ renderBrowser(makeApi({ list: jest.fn(async () => []), count: jest.fn(async () => 0) }));
278
+ expect(await screen.findByText("This set has no files yet")).toBeInTheDocument();
279
+ });
280
+
281
+ test("no results under a filter reads differently from an empty set", async () => {
282
+ renderBrowser(makeApi({ list: jest.fn(async () => []), count: jest.fn(async () => 0) }));
283
+ await screen.findByText("This set has no files yet");
284
+
285
+ fireEvent.change(screen.getByTestId("media-search"), { target: { value: "zzz" } });
286
+ expect(await screen.findByText("Nothing matches those filters", {}, { timeout: 2000 })).toBeInTheDocument();
287
+ });
288
+
289
+ test("without a media api the browser says media is unavailable", () => {
290
+ renderBrowser(undefined);
291
+ expect(screen.getByText("Media is unavailable")).toBeInTheDocument();
292
+ });
293
+
294
+ // Switching sets must not carry one library's filters onto another, where they
295
+ // could match nothing and look like an empty set.
296
+ test("changing set resets the search", async () => {
297
+ const api = makeApi();
298
+ const { rerender } = render(
299
+ <ThemeProvider>
300
+ <MediaProvider api={api}>
301
+ <MediaBrowser set="uploads" label="Uploads" />
302
+ </MediaProvider>
303
+ </ThemeProvider>,
304
+ );
305
+ await screen.findByTestId("media-grid");
306
+ fireEvent.change(screen.getByTestId("media-search"), { target: { value: "hero" } });
307
+ expect(screen.getByTestId("media-search")).toHaveValue("hero");
308
+
309
+ rerender(
310
+ <ThemeProvider>
311
+ <MediaProvider api={api}>
312
+ <MediaBrowser set="documents" label="Documents" />
313
+ </MediaProvider>
314
+ </ThemeProvider>,
315
+ );
316
+ await waitFor(() => expect(screen.getByTestId("media-search")).toHaveValue(""));
317
+ });
318
+ });
319
+
320
+ describe("conditionsFrom", () => {
321
+ const empty = { extensions: [], mimeTypes: [], kinds: [], minSize: "", maxSize: "" };
322
+
323
+ test("no selection means no conditions", () => {
324
+ expect(conditionsFrom(empty)).toEqual([]);
325
+ });
326
+
327
+ test("multi-select within a facet becomes one `in`", () => {
328
+ expect(conditionsFrom({ ...empty, extensions: ["png", "jpg"] })).toEqual([
329
+ { field: "extension", op: "in", values: ["png", "jpg"] },
330
+ ]);
331
+ });
332
+
333
+ test("size is entered in KB and sent in bytes", () => {
334
+ expect(conditionsFrom({ ...empty, minSize: "10", maxSize: "100" })).toEqual([
335
+ { field: "size", op: "gte", value: "10240" },
336
+ { field: "size", op: "lte", value: "102400" },
337
+ ]);
338
+ });
339
+
340
+ test("blank or nonsensical sizes are ignored rather than sent as NaN", () => {
341
+ expect(conditionsFrom({ ...empty, minSize: " ", maxSize: "abc" })).toEqual([]);
342
+ expect(conditionsFrom({ ...empty, minSize: "-5" })).toEqual([]);
343
+ });
344
+
345
+ test("different facets combine", () => {
346
+ const got = conditionsFrom({ ...empty, kinds: ["image"], extensions: ["png"], maxSize: "1" });
347
+ expect(got).toEqual([
348
+ { field: "extension", op: "in", values: ["png"] },
349
+ { field: "kind", op: "in", values: ["image"] },
350
+ { field: "size", op: "lte", value: "1024" },
351
+ ]);
352
+ });
353
+ });
@@ -0,0 +1,185 @@
1
+ import React from "react";
2
+ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
3
+ import { ThemeProvider } from "../ThemeProvider";
4
+ import { MediaField, MediaProvider } from "../components/MediaField";
5
+ import { pathForStoreAs } from "../components/MediaField";
6
+ import type { MediaApi, MediaAsset, MediaResolution } from "../media";
7
+
8
+ const hero: MediaAsset = {
9
+ id: "m1",
10
+ set: "uploads",
11
+ repoPath: "public/uploads/hero.png",
12
+ publicPath: "/uploads/hero.png",
13
+ cdnPath: "https://cdn.example.com/uploads/hero.png",
14
+ mimeType: "image/png",
15
+ extension: "png",
16
+ kind: "image",
17
+ size: 2048,
18
+ width: 800,
19
+ height: 600,
20
+ pending: false,
21
+ url: "https://store.example.com/signed/hero.png",
22
+ };
23
+
24
+ const manual: MediaAsset = {
25
+ ...hero,
26
+ id: "m2",
27
+ repoPath: "files/manual.pdf",
28
+ publicPath: null,
29
+ cdnPath: null,
30
+ mimeType: "application/pdf",
31
+ kind: "pdf",
32
+ width: null,
33
+ height: null,
34
+ url: "https://store.example.com/signed/manual.pdf",
35
+ };
36
+
37
+ function makeApi(over: Partial<MediaApi> = {}): MediaApi {
38
+ return {
39
+ sets: [{ name: "uploads", path: "public/uploads/**/*", mimeTypes: [], count: 2 }],
40
+ resolve: jest.fn(async (paths: string[]): Promise<MediaResolution[]> =>
41
+ paths.map((p) => ({ input: p, media: null, ambiguous: false, candidates: [] })),
42
+ ),
43
+ byId: jest.fn(async () => null),
44
+ list: jest.fn(async () => [hero, manual]),
45
+ ...over,
46
+ };
47
+ }
48
+
49
+ // api is deliberately not defaulted: passing `undefined` explicitly must mean
50
+ // "no host wiring", not "give me the default".
51
+ function renderField(ui: React.ReactElement, api: MediaApi | undefined) {
52
+ return render(
53
+ <ThemeProvider>
54
+ <MediaProvider api={api}>{ui}</MediaProvider>
55
+ </ThemeProvider>,
56
+ );
57
+ }
58
+
59
+ test("resolves a string value and previews the matched asset", async () => {
60
+ const api = makeApi({
61
+ resolve: jest.fn(async (paths: string[]) =>
62
+ paths.map((p) => ({ input: p, media: hero, aliasKind: "public", ambiguous: false, candidates: [] })),
63
+ ),
64
+ });
65
+ renderField(
66
+ <MediaField shape="string" value="/uploads/hero.png" onChange={jest.fn()} set="uploads" documentPath="posts/a.md" />,
67
+ api,
68
+ );
69
+
70
+ const img = await screen.findByRole("img");
71
+ expect(img).toHaveAttribute("src", hero.url);
72
+ // The document path travels with the request so relative references resolve
73
+ // against the right directory.
74
+ expect(api.resolve).toHaveBeenCalledWith(["/uploads/hero.png"], "posts/a.md");
75
+ // The raw stored string stays visible — the author needs to see what actually
76
+ // goes into the file.
77
+ expect(screen.getByText("/uploads/hero.png")).toBeInTheDocument();
78
+ });
79
+
80
+ test("a string that resolves to nothing renders as unmanaged, not as an error", async () => {
81
+ renderField(<MediaField shape="string" value="https://elsewhere.example/x.png" onChange={jest.fn()} set="uploads" />, makeApi());
82
+ expect(await screen.findByText("External link")).toBeInTheDocument();
83
+ // The value is preserved exactly; nothing was rewritten.
84
+ expect(screen.getByText("https://elsewhere.example/x.png")).toBeInTheDocument();
85
+ });
86
+
87
+ test("a path outside every media set is flagged without discarding the value", async () => {
88
+ renderField(<MediaField shape="string" value="images/gone.png" onChange={jest.fn()} set="uploads" />, makeApi());
89
+ expect(await screen.findByText("Not a managed asset")).toBeInTheDocument();
90
+ expect(screen.getByText("images/gone.png")).toBeInTheDocument();
91
+ });
92
+
93
+ test("an ambiguous basename offers candidates instead of picking one", async () => {
94
+ const api = makeApi({
95
+ resolve: jest.fn(async (paths: string[]) =>
96
+ paths.map((p) => ({
97
+ input: p,
98
+ media: null,
99
+ aliasKind: "basename",
100
+ ambiguous: true,
101
+ candidates: [hero, { ...hero, id: "m3", repoPath: "public/uploads/sub/hero.png" }],
102
+ })),
103
+ ),
104
+ });
105
+ const onChange = jest.fn();
106
+ renderField(<MediaField shape="string" value="hero.png" onChange={onChange} set="uploads" />, api);
107
+
108
+ expect(await screen.findByText(/2 files share this name/)).toBeInTheDocument();
109
+ // Nothing was auto-selected — a silently wrong image in a published page is
110
+ // far more expensive than one extra click.
111
+ expect(onChange).not.toHaveBeenCalled();
112
+
113
+ // Choosing a candidate resolves the ambiguity in the stored value.
114
+ fireEvent.click(screen.getByTestId("media-candidate-m3"));
115
+ expect(onChange).toHaveBeenCalledWith("/uploads/hero.png");
116
+ });
117
+
118
+ test("picking an asset writes the form the field's store_as declares", async () => {
119
+ const onChange = jest.fn();
120
+ renderField(<MediaField shape="string" value="" onChange={onChange} set="uploads" storeAs="repo" />, makeApi());
121
+
122
+ fireEvent.click(screen.getByTestId("media-choose"));
123
+ fireEvent.click(await screen.findByTestId("media-row-m1"));
124
+
125
+ expect(onChange).toHaveBeenCalledWith("public/uploads/hero.png");
126
+ });
127
+
128
+ test("the object shape stores the asset id, not a path", async () => {
129
+ const onChange = jest.fn();
130
+ renderField(<MediaField shape="object" value={null} onChange={onChange} set="uploads" />, makeApi());
131
+
132
+ fireEvent.click(screen.getByTestId("media-choose"));
133
+ fireEvent.click(await screen.findByTestId("media-row-m1"));
134
+
135
+ expect(onChange).toHaveBeenCalledWith({
136
+ id: "m1",
137
+ public_path: "/uploads/hero.png",
138
+ cdn_path: "https://cdn.example.com/uploads/hero.png",
139
+ });
140
+ });
141
+
142
+ test("the object shape fetches by id and never resolves", async () => {
143
+ const api = makeApi({ byId: jest.fn(async () => hero) });
144
+ renderField(<MediaField shape="object" value={{ id: "m1" }} onChange={jest.fn()} set="uploads" />, api);
145
+
146
+ await screen.findByRole("img");
147
+ expect(api.byId).toHaveBeenCalledWith("m1");
148
+ // The id is authoritative, so there is nothing to reverse-engineer.
149
+ expect(api.resolve).not.toHaveBeenCalled();
150
+ });
151
+
152
+ test("an object reference whose asset is gone shows as missing, keeping the value", async () => {
153
+ const api = makeApi({ byId: jest.fn(async () => null) });
154
+ const onChange = jest.fn();
155
+ renderField(<MediaField shape="object" value={{ id: "m9" }} onChange={onChange} set="uploads" />, api);
156
+
157
+ expect(await screen.findByText("Missing asset")).toBeInTheDocument();
158
+ // Silently blanking the field would destroy the evidence of what it pointed at.
159
+ expect(onChange).not.toHaveBeenCalled();
160
+ });
161
+
162
+ test("without a host media api the picker is disabled", () => {
163
+ renderField(<MediaField shape="string" value="" onChange={jest.fn()} set="uploads" />, undefined);
164
+ expect(screen.getByTestId("media-choose")).toBeDisabled();
165
+ });
166
+
167
+ test("readOnly hides the picker controls entirely", () => {
168
+ renderField(<MediaField shape="string" value="" onChange={jest.fn()} set="uploads" readOnly />, makeApi());
169
+ expect(screen.queryByTestId("media-choose")).not.toBeInTheDocument();
170
+ });
171
+
172
+ describe("pathForStoreAs", () => {
173
+ test("writes each declared form", () => {
174
+ expect(pathForStoreAs(hero, "repo")).toBe("public/uploads/hero.png");
175
+ expect(pathForStoreAs(hero, "public")).toBe("/uploads/hero.png");
176
+ expect(pathForStoreAs(hero, "cdn")).toBe("https://cdn.example.com/uploads/hero.png");
177
+ });
178
+
179
+ test("falls back down the chain when a set declares no base", () => {
180
+ // A set with no public_base/cdn_base serves files at their repository paths,
181
+ // so that is the only honest thing to write.
182
+ expect(pathForStoreAs(manual, "cdn")).toBe("files/manual.pdf");
183
+ expect(pathForStoreAs(manual, "public")).toBe("files/manual.pdf");
184
+ });
185
+ });
@@ -0,0 +1,69 @@
1
+ import React from "react";
2
+ import { render, fireEvent, screen } from "@testing-library/react";
3
+ import { ThemeProvider } from "../ThemeProvider";
4
+ import { NotificationBell, NotificationList, type NotificationItem } from "../components/Notifications";
5
+
6
+ const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
7
+
8
+ const items: NotificationItem[] = [
9
+ { id: "1", title: "Export failed", body: "conflict", level: "error", read: false, createdAt: new Date().toISOString() },
10
+ { id: "2", title: "Import completed", level: "info", read: true, createdAt: new Date().toISOString() },
11
+ ];
12
+
13
+ test("bell shows an unread dot only when there are unread notifications", () => {
14
+ const { rerender } = render(wrap(<NotificationBell notifications={items} unreadCount={2} />));
15
+ expect(screen.getByTestId("notification-unread-dot")).toBeInTheDocument();
16
+
17
+ rerender(wrap(<NotificationBell notifications={items} unreadCount={0} />));
18
+ expect(screen.queryByTestId("notification-unread-dot")).not.toBeInTheDocument();
19
+ });
20
+
21
+ test("opening the bell reveals the menu, calls onOpen, and lists notifications", () => {
22
+ const onOpen = jest.fn();
23
+ render(wrap(<NotificationBell notifications={items} unreadCount={2} onOpen={onOpen} />));
24
+ expect(screen.queryByTestId("notification-menu")).not.toBeInTheDocument();
25
+
26
+ fireEvent.click(screen.getByTestId("notification-bell"));
27
+ expect(onOpen).toHaveBeenCalledTimes(1);
28
+ const menu = screen.getByTestId("notification-menu");
29
+ expect(menu).toHaveTextContent("Export failed");
30
+ expect(menu).toHaveTextContent("Import completed");
31
+ });
32
+
33
+ test("Read more fires onViewAll and closes the menu", () => {
34
+ const onViewAll = jest.fn();
35
+ render(wrap(<NotificationBell notifications={items} unreadCount={2} onViewAll={onViewAll} />));
36
+ fireEvent.click(screen.getByTestId("notification-bell"));
37
+ fireEvent.click(screen.getByTestId("notification-read-more"));
38
+ expect(onViewAll).toHaveBeenCalledTimes(1);
39
+ expect(screen.queryByTestId("notification-menu")).not.toBeInTheDocument();
40
+ });
41
+
42
+ test("empty bell shows the caught-up message", () => {
43
+ render(wrap(<NotificationBell notifications={[]} unreadCount={0} />));
44
+ fireEvent.click(screen.getByTestId("notification-bell"));
45
+ expect(screen.getByTestId("notification-empty")).toBeInTheDocument();
46
+ });
47
+
48
+ test("NotificationList renders rows and empty state", () => {
49
+ const { rerender } = render(wrap(<NotificationList notifications={items} />));
50
+ expect(screen.getByTestId("notification-list")).toHaveTextContent("Export failed");
51
+
52
+ rerender(wrap(<NotificationList notifications={[]} />));
53
+ expect(screen.getByTestId("notification-list-empty")).toBeInTheDocument();
54
+ });
55
+
56
+ test("bell becomes a spinner while busy, keeping the menu and the unread dot", () => {
57
+ const onOpen = jest.fn();
58
+ const { rerender } = render(wrap(<NotificationBell notifications={items} unreadCount={2} />));
59
+ expect(screen.queryByTestId("notification-busy")).not.toBeInTheDocument();
60
+
61
+ rerender(wrap(<NotificationBell notifications={items} unreadCount={2} onOpen={onOpen} busy />));
62
+ expect(screen.getByTestId("notification-busy")).toBeInTheDocument();
63
+ // Unread notifications don't stop being unread because an import is running.
64
+ expect(screen.getByTestId("notification-unread-dot")).toBeInTheDocument();
65
+ // And the menu still opens — the spinner replaces the glyph, not the control.
66
+ fireEvent.click(screen.getByTestId("notification-bell"));
67
+ expect(onOpen).toHaveBeenCalled();
68
+ expect(screen.getByTestId("notification-menu")).toBeInTheDocument();
69
+ });