@gogitcms/design-system 0.16.0-next.1 → 0.16.0-next.10
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/package.json +1 -1
- package/src/__tests__/ApplyChangesModal.test.tsx +48 -0
- package/src/__tests__/BranchDeletedModal.test.tsx +42 -0
- package/src/__tests__/ContentBrowser.fieldfocus.test.tsx +109 -0
- package/src/__tests__/ContentBrowser.forms.test.tsx +47 -1
- package/src/__tests__/ContentBrowser.history.test.tsx +385 -0
- package/src/__tests__/ContentBrowser.historycollab.test.tsx +433 -0
- package/src/__tests__/ContentBrowser.slotmedia.test.tsx +150 -0
- package/src/__tests__/MediaField.test.tsx +18 -0
- package/src/__tests__/ProtectedBranchModal.test.tsx +68 -0
- package/src/components/ApplyChangesModal.tsx +70 -21
- package/src/components/BranchDeletedModal.tsx +111 -0
- package/src/components/ChangeDetail.tsx +94 -11
- package/src/components/CollabField.tsx +71 -0
- package/src/components/ContentBrowser.tsx +407 -68
- package/src/components/DocumentHistory.tsx +408 -0
- package/src/components/MediaField.tsx +62 -0
- package/src/components/MediaPreview.tsx +14 -1
- package/src/components/MediaPreview.web.tsx +18 -3
- package/src/components/Notifications.tsx +146 -28
- package/src/components/ProtectedBranchModal.tsx +136 -0
- package/src/components/primitives.tsx +46 -1
- package/src/history.ts +82 -0
- package/src/icons.ts +1 -0
- package/src/index.ts +13 -3
- package/src/media.ts +15 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
// Restoring a field from a past version, in a live session.
|
|
2
|
+
//
|
|
3
|
+
// In collab the shared document is the source of truth: every collab control
|
|
4
|
+
// prefers its binding to the value the host passes in. So a restore written only
|
|
5
|
+
// to local form state was displayed for one frame and then overwritten by the
|
|
6
|
+
// CRDT's unchanged value — the field visibly snapped back, and nothing about the
|
|
7
|
+
// restore reached the room or the peers in it.
|
|
8
|
+
|
|
9
|
+
import React from "react";
|
|
10
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
11
|
+
import { ThemeProvider } from "../ThemeProvider";
|
|
12
|
+
import {
|
|
13
|
+
ContentBrowser,
|
|
14
|
+
type CmsEntry,
|
|
15
|
+
type CmsNavSection,
|
|
16
|
+
type CollabApi,
|
|
17
|
+
type CollabRegister,
|
|
18
|
+
type CollabText,
|
|
19
|
+
type FieldSlotArgs,
|
|
20
|
+
} from "../components/ContentBrowser";
|
|
21
|
+
import type { DocumentChange } from "../components/ChangeDetail";
|
|
22
|
+
import type { HistoryApi } from "../history";
|
|
23
|
+
import { refreshDrafts } from "../components/documentDrafts";
|
|
24
|
+
|
|
25
|
+
jest.mock("../ThemeProvider", () => {
|
|
26
|
+
const actual = jest.requireActual("../ThemeProvider");
|
|
27
|
+
return { ...actual, useResponsive: () => ({ width: 1300, height: 900, isDesktop: true, isMobile: false }) };
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* An in-memory stand-in for @gogitcms/realtime's DocCollab, seeded with what the
|
|
34
|
+
* room already holds. Bindings are memoized per path and the empty peer list is
|
|
35
|
+
* shared, because useSyncExternalStore re-renders until a snapshot is stable.
|
|
36
|
+
*/
|
|
37
|
+
function fakeCollab(seed: Record<string, unknown> = {}) {
|
|
38
|
+
const values = new Map<string, unknown>(Object.entries(seed));
|
|
39
|
+
const listeners = new Map<string, Set<() => void>>();
|
|
40
|
+
const noPeers: never[] = [];
|
|
41
|
+
|
|
42
|
+
const notify = (path: string) => listeners.get(path)?.forEach((l) => l());
|
|
43
|
+
const listen = (path: string, cb: () => void) => {
|
|
44
|
+
let set = listeners.get(path);
|
|
45
|
+
if (!set) listeners.set(path, (set = new Set()));
|
|
46
|
+
set.add(cb);
|
|
47
|
+
return () => void set!.delete(cb);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const textCache = new Map<string, CollabText>();
|
|
51
|
+
const text = (path: string): CollabText => {
|
|
52
|
+
let b = textCache.get(path);
|
|
53
|
+
if (!b) {
|
|
54
|
+
textCache.set(path, (b = {
|
|
55
|
+
get: () => String(values.get(path) ?? ""),
|
|
56
|
+
set: (next) => { values.set(path, next); notify(path); },
|
|
57
|
+
peers: () => noPeers,
|
|
58
|
+
subscribe: (cb) => listen(path, cb),
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
return b;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const regCache = new Map<string, CollabRegister>();
|
|
65
|
+
const register = (path: string): CollabRegister => {
|
|
66
|
+
let b = regCache.get(path);
|
|
67
|
+
if (!b) {
|
|
68
|
+
regCache.set(path, (b = {
|
|
69
|
+
get: () => values.get(path),
|
|
70
|
+
set: (next) => { values.set(path, next); notify(path); },
|
|
71
|
+
subscribe: (cb) => listen(path, cb),
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
return b;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const collab: CollabApi = {
|
|
78
|
+
text, register,
|
|
79
|
+
setFocus: () => {},
|
|
80
|
+
peersAt: () => noPeers,
|
|
81
|
+
peersUnder: () => noPeers,
|
|
82
|
+
participants: () => noPeers,
|
|
83
|
+
awarenessVersion: () => 0,
|
|
84
|
+
subscribeAwareness: () => () => {},
|
|
85
|
+
ydoc: {},
|
|
86
|
+
awareness: {},
|
|
87
|
+
};
|
|
88
|
+
return { collab, room: values };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A restore commits, and committing records a draft. Without clearing them each
|
|
92
|
+
// test seeds its form from the PREVIOUS test's restore — which reads as a field
|
|
93
|
+
// that was already rolled back and so has nothing left to write.
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
localStorage.clear();
|
|
96
|
+
refreshDrafts();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const sections: CmsNavSection[] = [
|
|
100
|
+
{ title: "Content", items: [{ key: "posts", label: "Posts", icon: "newspaper" }] },
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
const version = {
|
|
104
|
+
sha: "3b7de1098877",
|
|
105
|
+
shortSha: "3b7de10",
|
|
106
|
+
message: "Draft the post",
|
|
107
|
+
authorName: "Ada",
|
|
108
|
+
authoredAt: "2026-02-01T12:00:00Z",
|
|
109
|
+
url: "",
|
|
110
|
+
filesChanged: 1,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
function makeApi(detail: DocumentChange): HistoryApi {
|
|
114
|
+
return { list: jest.fn(async () => [version]), get: jest.fn(async () => detail) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function openHistory(entry: CmsEntry, detail: DocumentChange, renderField?: (a: FieldSlotArgs) => React.ReactNode) {
|
|
118
|
+
render(
|
|
119
|
+
wrap(
|
|
120
|
+
<ContentBrowser
|
|
121
|
+
workspace={{ name: "acme/site", initials: "AC", branch: "main", changed: 0 }}
|
|
122
|
+
sections={sections}
|
|
123
|
+
activeNavKey="posts"
|
|
124
|
+
onSelectNav={jest.fn()}
|
|
125
|
+
entries={[entry]}
|
|
126
|
+
selectedEntryId={entry.id}
|
|
127
|
+
onSelectEntry={jest.fn()}
|
|
128
|
+
userInitials="ED"
|
|
129
|
+
onSaveEntry={jest.fn(async () => {})}
|
|
130
|
+
renderField={renderField}
|
|
131
|
+
history={makeApi(detail)}
|
|
132
|
+
/>,
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
fireEvent.click(screen.getByTestId("document-history-toggle"));
|
|
136
|
+
await screen.findByTestId("change-detail");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The regression, end to end: the restored value has to be what the control
|
|
140
|
+
// shows once the CRDT has had its say — not for one frame before it.
|
|
141
|
+
test("a restored string lands in the room, and stays on screen", async () => {
|
|
142
|
+
const { collab, room } = fakeCollab({ status: "published" });
|
|
143
|
+
const entry: CmsEntry = {
|
|
144
|
+
id: "a", path: "posts/a.md", title: "Alpha", body: "", collab,
|
|
145
|
+
fields: [{ name: "status", type: "string", label: "Status", value: "published" }],
|
|
146
|
+
};
|
|
147
|
+
await openHistory(entry, {
|
|
148
|
+
path: "posts/a.md", status: "M", label: "Alpha", added: 1, removed: 1,
|
|
149
|
+
fields: [{ name: "status", kind: "changed", before: "draft", after: "published" }],
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
fireEvent.click(screen.getByTestId("restore-field-status"));
|
|
153
|
+
fireEvent.click(screen.getByTestId("restore-apply"));
|
|
154
|
+
|
|
155
|
+
// Written where a string lives — the Y.Text, which is what the control reads.
|
|
156
|
+
expect(room.get("status")).toBe("draft");
|
|
157
|
+
await waitFor(() => expect(screen.getByDisplayValue("draft")).toBeInTheDocument());
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Which shared type a value lives in follows its shape, mirroring what the
|
|
161
|
+
// server seeds: a string is a text, everything else is a register. Writing to
|
|
162
|
+
// the wrong one is not an error anyone sees — it is a value that lands where
|
|
163
|
+
// nothing is looking.
|
|
164
|
+
test("non-string values restore into the register, not the text", async () => {
|
|
165
|
+
const { collab, room } = fakeCollab({ weight: 20, tags: ["b"], live: true });
|
|
166
|
+
const entry: CmsEntry = {
|
|
167
|
+
id: "a", path: "posts/a.md", title: "Alpha", body: "", collab,
|
|
168
|
+
fields: [
|
|
169
|
+
{ name: "weight", type: "integer", value: 20 },
|
|
170
|
+
{ name: "tags", type: "array", of: "string", value: ["b"] },
|
|
171
|
+
{ name: "live", type: "boolean", value: true },
|
|
172
|
+
],
|
|
173
|
+
};
|
|
174
|
+
await openHistory(entry, {
|
|
175
|
+
path: "posts/a.md", status: "M", label: "Alpha", added: 1, removed: 1,
|
|
176
|
+
fields: [
|
|
177
|
+
{ name: "weight", kind: "changed", before: 10, after: 20 },
|
|
178
|
+
{ name: "tags", kind: "changed", before: ["a"], after: ["b"] },
|
|
179
|
+
{ name: "live", kind: "changed", before: false, after: true },
|
|
180
|
+
],
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
fireEvent.click(screen.getByTestId("restore-field-weight"));
|
|
184
|
+
fireEvent.click(screen.getByTestId("restore-field-tags"));
|
|
185
|
+
fireEvent.click(screen.getByTestId("restore-field-live"));
|
|
186
|
+
fireEvent.click(screen.getByTestId("restore-apply"));
|
|
187
|
+
|
|
188
|
+
expect(room.get("weight")).toBe(10);
|
|
189
|
+
expect(room.get("tags")).toEqual(["a"]);
|
|
190
|
+
expect(room.get("live")).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// A group's children are addressed by dotted path in the room, exactly as the
|
|
194
|
+
// server writes them — a group restored as one object still has to reach the
|
|
195
|
+
// individual leaves its controls read.
|
|
196
|
+
test("a restored group writes its children at their dotted paths", async () => {
|
|
197
|
+
const { collab, room } = fakeCollab({ "seo.title": "New", "seo.weight": 2 });
|
|
198
|
+
const entry: CmsEntry = {
|
|
199
|
+
id: "a", path: "posts/a.md", title: "Alpha", body: "", collab,
|
|
200
|
+
fields: [{
|
|
201
|
+
name: "seo", type: "object", component: "group",
|
|
202
|
+
fields: [
|
|
203
|
+
{ name: "title", type: "string", value: "New" },
|
|
204
|
+
{ name: "weight", type: "integer", value: 2 },
|
|
205
|
+
],
|
|
206
|
+
value: { title: "New", weight: 2 },
|
|
207
|
+
}],
|
|
208
|
+
};
|
|
209
|
+
await openHistory(entry, {
|
|
210
|
+
path: "posts/a.md", status: "M", label: "Alpha", added: 1, removed: 1,
|
|
211
|
+
fields: [{
|
|
212
|
+
name: "seo", kind: "changed",
|
|
213
|
+
before: { title: "Old", weight: 1 },
|
|
214
|
+
after: { title: "New", weight: 2 },
|
|
215
|
+
}],
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
fireEvent.click(screen.getByTestId("restore-field-seo"));
|
|
219
|
+
fireEvent.click(screen.getByTestId("restore-apply"));
|
|
220
|
+
|
|
221
|
+
expect(room.get("seo.title")).toBe("Old");
|
|
222
|
+
expect(room.get("seo.weight")).toBe(1);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Only the new value's leaves say what to write; only the old value's leaves say
|
|
226
|
+
// what to CLEAR. A group that loses a child would otherwise keep a shared value
|
|
227
|
+
// nobody can see any more.
|
|
228
|
+
test("leaves the restored version does not have are cleared in the room", async () => {
|
|
229
|
+
const { collab, room } = fakeCollab({ "seo.title": "New", "seo.blurb": "extra", subtitle: "here" });
|
|
230
|
+
const entry: CmsEntry = {
|
|
231
|
+
id: "a", path: "posts/a.md", title: "Alpha", body: "", collab,
|
|
232
|
+
fields: [
|
|
233
|
+
{ name: "seo", type: "object", component: "group", fields: [{ name: "title", type: "string", value: "New" }], value: { title: "New", blurb: "extra" } },
|
|
234
|
+
{ name: "subtitle", type: "string", value: "here" },
|
|
235
|
+
],
|
|
236
|
+
};
|
|
237
|
+
await openHistory(entry, {
|
|
238
|
+
path: "posts/a.md", status: "M", label: "Alpha", added: 1, removed: 1,
|
|
239
|
+
fields: [
|
|
240
|
+
// The old version's group had no `blurb` at all…
|
|
241
|
+
{ name: "seo", kind: "changed", before: { title: "Old" }, after: { title: "New", blurb: "extra" } },
|
|
242
|
+
// …and had no `subtitle` field whatsoever.
|
|
243
|
+
{ name: "subtitle", kind: "added", before: undefined, after: "here" },
|
|
244
|
+
],
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
fireEvent.click(screen.getByTestId("restore-field-seo"));
|
|
248
|
+
fireEvent.click(screen.getByTestId("restore-field-subtitle"));
|
|
249
|
+
fireEvent.click(screen.getByTestId("restore-apply"));
|
|
250
|
+
|
|
251
|
+
expect(room.get("seo.title")).toBe("Old");
|
|
252
|
+
// Emptied rather than left holding a value the restored version never had.
|
|
253
|
+
expect(room.get("seo.blurb")).toBe("");
|
|
254
|
+
expect(room.get("subtitle")).toBe("");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// The body is a ProseMirror fragment, not a text binding: in a session it
|
|
258
|
+
// ignores the `value` it is handed and mirrors the shared fragment, so "the
|
|
259
|
+
// value changed" is not something it can observe. It is told instead.
|
|
260
|
+
test("restoring the body signals the body editor to replace its content", async () => {
|
|
261
|
+
const { collab } = fakeCollab({});
|
|
262
|
+
const seen: FieldSlotArgs[] = [];
|
|
263
|
+
const renderField = (args: FieldSlotArgs) => {
|
|
264
|
+
if (args.field.source !== "body") return undefined;
|
|
265
|
+
seen.push(args);
|
|
266
|
+
return <div data-testid="body-slot" />;
|
|
267
|
+
};
|
|
268
|
+
const entry: CmsEntry = {
|
|
269
|
+
id: "a", path: "posts/a.md", title: "Alpha", body: "Now.", collab,
|
|
270
|
+
fields: [
|
|
271
|
+
{ name: "status", type: "string", value: "published" },
|
|
272
|
+
{ name: "content", type: "string", component: "body", source: "body", value: "Now." },
|
|
273
|
+
],
|
|
274
|
+
};
|
|
275
|
+
await openHistory(entry, {
|
|
276
|
+
path: "posts/a.md", status: "M", label: "Alpha", added: 1, removed: 1,
|
|
277
|
+
fields: [{ name: "status", kind: "changed", before: "draft", after: "published" }],
|
|
278
|
+
bodyBefore: "Then.", bodyAfter: "Now.",
|
|
279
|
+
}, renderField);
|
|
280
|
+
|
|
281
|
+
const before = seen[seen.length - 1].resetNonce;
|
|
282
|
+
|
|
283
|
+
fireEvent.click(screen.getByTestId("restore-field-body"));
|
|
284
|
+
fireEvent.click(screen.getByTestId("restore-apply"));
|
|
285
|
+
|
|
286
|
+
await waitFor(() => {
|
|
287
|
+
const last = seen[seen.length - 1];
|
|
288
|
+
expect(last.value).toBe("Then.");
|
|
289
|
+
expect(last.resetNonce).not.toBe(before);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Restoring some other field must not make the body editor replace itself — a
|
|
294
|
+
// peer could be mid-sentence in it.
|
|
295
|
+
test("restoring another field leaves the body editor's signal alone", async () => {
|
|
296
|
+
const { collab } = fakeCollab({ status: "published" });
|
|
297
|
+
const seen: FieldSlotArgs[] = [];
|
|
298
|
+
const renderField = (args: FieldSlotArgs) => {
|
|
299
|
+
if (args.field.source !== "body") return undefined;
|
|
300
|
+
seen.push(args);
|
|
301
|
+
return <div data-testid="body-slot" />;
|
|
302
|
+
};
|
|
303
|
+
const entry: CmsEntry = {
|
|
304
|
+
id: "a", path: "posts/a.md", title: "Alpha", body: "Now.", collab,
|
|
305
|
+
fields: [
|
|
306
|
+
{ name: "status", type: "string", value: "published" },
|
|
307
|
+
{ name: "content", type: "string", component: "body", source: "body", value: "Now." },
|
|
308
|
+
],
|
|
309
|
+
};
|
|
310
|
+
await openHistory(entry, {
|
|
311
|
+
path: "posts/a.md", status: "M", label: "Alpha", added: 1, removed: 1,
|
|
312
|
+
fields: [{ name: "status", kind: "changed", before: "draft", after: "published" }],
|
|
313
|
+
bodyBefore: "Then.", bodyAfter: "Now.",
|
|
314
|
+
}, renderField);
|
|
315
|
+
|
|
316
|
+
const before = seen[seen.length - 1].resetNonce;
|
|
317
|
+
|
|
318
|
+
fireEvent.click(screen.getByTestId("restore-field-status"));
|
|
319
|
+
fireEvent.click(screen.getByTestId("restore-apply"));
|
|
320
|
+
|
|
321
|
+
await waitFor(() => expect(screen.getByTestId("column-scroll")).toBeInTheDocument());
|
|
322
|
+
expect(seen[seen.length - 1].resetNonce).toBe(before);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// ---- discard, in a live session -------------------------------------------
|
|
326
|
+
//
|
|
327
|
+
// Discard has the same shape of problem as a restore, and had the same bug: it
|
|
328
|
+
// reverted the local form and left the discarded work sitting in the CRDT,
|
|
329
|
+
// where the control read it straight back. In a session a discard is a change
|
|
330
|
+
// to the ROOM — the unsaved work is the collective state every client is
|
|
331
|
+
// looking at, not one screen's private draft.
|
|
332
|
+
|
|
333
|
+
async function openEditor(entry: CmsEntry, renderField?: (a: FieldSlotArgs) => React.ReactNode) {
|
|
334
|
+
render(
|
|
335
|
+
wrap(
|
|
336
|
+
<ContentBrowser
|
|
337
|
+
workspace={{ name: "acme/site", initials: "AC", branch: "main", changed: 0 }}
|
|
338
|
+
sections={sections}
|
|
339
|
+
activeNavKey="posts"
|
|
340
|
+
onSelectNav={jest.fn()}
|
|
341
|
+
entries={[entry]}
|
|
342
|
+
selectedEntryId={entry.id}
|
|
343
|
+
onSelectEntry={jest.fn()}
|
|
344
|
+
userInitials="ED"
|
|
345
|
+
onSaveEntry={jest.fn(async () => {})}
|
|
346
|
+
renderField={renderField}
|
|
347
|
+
/>,
|
|
348
|
+
),
|
|
349
|
+
);
|
|
350
|
+
await screen.findByTestId("column-scroll");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
test("discarding puts the saved value back in the room, not just on screen", async () => {
|
|
354
|
+
const { collab, room } = fakeCollab({ status: "published" });
|
|
355
|
+
const entry: CmsEntry = {
|
|
356
|
+
id: "a", path: "posts/a.md", title: "Alpha", body: "", collab,
|
|
357
|
+
fields: [{ name: "status", type: "string", label: "Status", value: "published" }],
|
|
358
|
+
};
|
|
359
|
+
await openEditor(entry);
|
|
360
|
+
|
|
361
|
+
fireEvent.change(screen.getByDisplayValue("published"), { target: { value: "scratch" } });
|
|
362
|
+
await waitFor(() => expect(room.get("status")).toBe("scratch"));
|
|
363
|
+
|
|
364
|
+
fireEvent.click(await screen.findByTestId("discard-changes"));
|
|
365
|
+
|
|
366
|
+
// The room is back to the document, so the control has nothing to read back.
|
|
367
|
+
expect(room.get("status")).toBe("published");
|
|
368
|
+
await waitFor(() => expect(screen.getByDisplayValue("published")).toBeInTheDocument());
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// The body is a fragment the editor mirrors, so a discard has to tell it rather
|
|
372
|
+
// than write a binding it does not read.
|
|
373
|
+
test("discarding signals the body editor to rebuild from the saved document", async () => {
|
|
374
|
+
const { collab } = fakeCollab({ status: "published" });
|
|
375
|
+
const seen: FieldSlotArgs[] = [];
|
|
376
|
+
const renderField = (args: FieldSlotArgs) => {
|
|
377
|
+
if (args.field.source !== "body") return undefined;
|
|
378
|
+
seen.push(args);
|
|
379
|
+
return <div data-testid="body-slot" />;
|
|
380
|
+
};
|
|
381
|
+
const entry: CmsEntry = {
|
|
382
|
+
id: "a", path: "posts/a.md", title: "Alpha", body: "Saved.", collab,
|
|
383
|
+
fields: [
|
|
384
|
+
{ name: "status", type: "string", value: "published" },
|
|
385
|
+
{ name: "content", type: "string", component: "body", source: "body", value: "Saved." },
|
|
386
|
+
],
|
|
387
|
+
};
|
|
388
|
+
await openEditor(entry, renderField);
|
|
389
|
+
|
|
390
|
+
const before = seen[seen.length - 1].resetNonce;
|
|
391
|
+
// An edit to the body, as the editor itself would report one.
|
|
392
|
+
seen[seen.length - 1].onChange("Scratch.");
|
|
393
|
+
|
|
394
|
+
fireEvent.click(await screen.findByTestId("discard-changes"));
|
|
395
|
+
|
|
396
|
+
await waitFor(() => {
|
|
397
|
+
const last = seen[seen.length - 1];
|
|
398
|
+
expect(last.value).toBe("Saved.");
|
|
399
|
+
expect(last.resetNonce).not.toBe(before);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// A discard replaces the whole document, but only the fields that actually
|
|
404
|
+
// moved should be written: churning the rest costs CRDT operations and can
|
|
405
|
+
// nudge the cursor of a peer working in a field nobody discarded.
|
|
406
|
+
test("discarding leaves untouched fields alone in the room", async () => {
|
|
407
|
+
const { collab } = fakeCollab({ status: "published", subtitle: "untouched" });
|
|
408
|
+
const writes: string[] = [];
|
|
409
|
+
const wrapped: CollabApi = {
|
|
410
|
+
...collab,
|
|
411
|
+
text: (p) => {
|
|
412
|
+
const b = collab.text(p);
|
|
413
|
+
return { ...b, set: (n) => { writes.push(p); b.set(n); } };
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
const entry: CmsEntry = {
|
|
417
|
+
id: "a", path: "posts/a.md", title: "Alpha", body: "", collab: wrapped,
|
|
418
|
+
fields: [
|
|
419
|
+
{ name: "status", type: "string", label: "Status", value: "published" },
|
|
420
|
+
{ name: "subtitle", type: "string", label: "Subtitle", value: "untouched" },
|
|
421
|
+
],
|
|
422
|
+
};
|
|
423
|
+
await openEditor(entry);
|
|
424
|
+
|
|
425
|
+
fireEvent.change(screen.getByDisplayValue("published"), { target: { value: "scratch" } });
|
|
426
|
+
await waitFor(() => expect(screen.getByDisplayValue("scratch")).toBeInTheDocument());
|
|
427
|
+
writes.length = 0;
|
|
428
|
+
|
|
429
|
+
fireEvent.click(await screen.findByTestId("discard-changes"));
|
|
430
|
+
|
|
431
|
+
expect(writes).toContain("status");
|
|
432
|
+
expect(writes).not.toContain("subtitle");
|
|
433
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen, fireEvent, act } from "@testing-library/react";
|
|
3
|
+
import { ThemeProvider } from "../ThemeProvider";
|
|
4
|
+
import {
|
|
5
|
+
ContentBrowser,
|
|
6
|
+
type CmsEntry,
|
|
7
|
+
type CmsNavSection,
|
|
8
|
+
type EntryField,
|
|
9
|
+
type FieldSlotArgs,
|
|
10
|
+
} from "../components/ContentBrowser";
|
|
11
|
+
import type { MediaApi, MediaAsset, MediaResolution } from "../media";
|
|
12
|
+
|
|
13
|
+
// Force the desktop layout (jsdom reports width 0 → mobile otherwise).
|
|
14
|
+
jest.mock("../ThemeProvider", () => {
|
|
15
|
+
const actual = jest.requireActual("../ThemeProvider");
|
|
16
|
+
return { ...actual, useResponsive: () => ({ width: 1300, height: 900, isDesktop: true, isMobile: false }) };
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const hero: MediaAsset = {
|
|
20
|
+
id: "m1",
|
|
21
|
+
set: "uploads",
|
|
22
|
+
repoPath: "public/uploads/hero.png",
|
|
23
|
+
publicPath: "/uploads/hero.png",
|
|
24
|
+
cdnPath: "https://cdn.example.com/uploads/hero.png",
|
|
25
|
+
mimeType: "image/png",
|
|
26
|
+
extension: "png",
|
|
27
|
+
kind: "image",
|
|
28
|
+
size: 2048,
|
|
29
|
+
width: 800,
|
|
30
|
+
height: 600,
|
|
31
|
+
pending: false,
|
|
32
|
+
url: "https://store.example.com/signed/hero.png",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function makeApi(over: Partial<MediaApi> = {}): MediaApi {
|
|
36
|
+
return {
|
|
37
|
+
sets: [{ name: "uploads", path: "public/uploads/**/*", mimeTypes: [], count: 1 }],
|
|
38
|
+
resolve: jest.fn(async (paths: string[]): Promise<MediaResolution[]> =>
|
|
39
|
+
paths.map((p) => ({ input: p, media: hero, aliasKind: "public", ambiguous: false, candidates: [] })),
|
|
40
|
+
),
|
|
41
|
+
byId: jest.fn(async () => hero),
|
|
42
|
+
list: jest.fn(async () => [hero]),
|
|
43
|
+
...over,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const sections: CmsNavSection[] = [
|
|
48
|
+
{ title: "Content", items: [{ key: "posts", label: "Posts", icon: "newspaper" }] },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const bodyField: EntryField = {
|
|
52
|
+
name: "body",
|
|
53
|
+
label: "Body",
|
|
54
|
+
type: "string",
|
|
55
|
+
component: "body",
|
|
56
|
+
source: "body",
|
|
57
|
+
format: "markdown",
|
|
58
|
+
value: "",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function renderWith(entry: CmsEntry, api: MediaApi | undefined, renderField: (a: FieldSlotArgs) => React.ReactNode) {
|
|
62
|
+
render(
|
|
63
|
+
<ThemeProvider>
|
|
64
|
+
<ContentBrowser
|
|
65
|
+
workspace={{ name: "acme/site", initials: "AC", branch: "main", changed: 0 }}
|
|
66
|
+
sections={sections}
|
|
67
|
+
activeNavKey="posts"
|
|
68
|
+
onSelectNav={() => {}}
|
|
69
|
+
entries={[entry]}
|
|
70
|
+
userInitials="ED"
|
|
71
|
+
media={api}
|
|
72
|
+
renderField={renderField}
|
|
73
|
+
/>
|
|
74
|
+
</ThemeProvider>,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A slot control that exposes what it received: a button to open the picker
|
|
79
|
+
// and a place to show what the pick settled with.
|
|
80
|
+
function makeSlot() {
|
|
81
|
+
const seen: { media?: FieldSlotArgs["media"] } = {};
|
|
82
|
+
const renderField = (args: FieldSlotArgs) => {
|
|
83
|
+
if (args.field.component !== "body") return undefined;
|
|
84
|
+
seen.media = args.media;
|
|
85
|
+
return <button data-testid="slot-control">custom body</button>;
|
|
86
|
+
};
|
|
87
|
+
return { seen, renderField };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
test("a slot-rendered field receives media access that opens the picker and resolves paths", async () => {
|
|
91
|
+
const api = makeApi();
|
|
92
|
+
const { seen, renderField } = makeSlot();
|
|
93
|
+
const entry: CmsEntry = { id: "a", path: "content/posts/a.md", title: "Alpha", body: "", fields: [bodyField] };
|
|
94
|
+
renderWith(entry, api, renderField);
|
|
95
|
+
|
|
96
|
+
await screen.findByTestId("slot-control");
|
|
97
|
+
expect(seen.media).toBeDefined();
|
|
98
|
+
|
|
99
|
+
// Resolution is bound to the open document, like a media field's.
|
|
100
|
+
const resolved = await seen.media!.resolve(["/uploads/hero.png"]);
|
|
101
|
+
expect(resolved[0].media).toEqual(hero);
|
|
102
|
+
expect(api.resolve).toHaveBeenCalledWith(["/uploads/hero.png"], "content/posts/a.md");
|
|
103
|
+
|
|
104
|
+
// pick() opens the picker; choosing a row settles it with the asset and the
|
|
105
|
+
// path in the field's store_as form (public by default).
|
|
106
|
+
let pending: Promise<{ asset: MediaAsset; path: string } | null>;
|
|
107
|
+
act(() => {
|
|
108
|
+
pending = seen.media!.pick({ kinds: ["image", "svg"] });
|
|
109
|
+
});
|
|
110
|
+
const row = await screen.findByTestId(`media-row-${hero.id}`);
|
|
111
|
+
expect(api.list).toHaveBeenCalledWith(expect.objectContaining({ set: "uploads", kinds: ["image", "svg"] }));
|
|
112
|
+
fireEvent.click(row);
|
|
113
|
+
await expect(pending!).resolves.toEqual({ asset: hero, path: "/uploads/hero.png" });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("dismissing the picker settles pick() with null", async () => {
|
|
117
|
+
const api = makeApi();
|
|
118
|
+
const { seen, renderField } = makeSlot();
|
|
119
|
+
const entry: CmsEntry = { id: "a", path: "content/posts/a.md", title: "Alpha", body: "", fields: [bodyField] };
|
|
120
|
+
renderWith(entry, api, renderField);
|
|
121
|
+
await screen.findByTestId("slot-control");
|
|
122
|
+
|
|
123
|
+
let pending: Promise<unknown>;
|
|
124
|
+
act(() => {
|
|
125
|
+
pending = seen.media!.pick();
|
|
126
|
+
});
|
|
127
|
+
await screen.findByTestId(`media-row-${hero.id}`);
|
|
128
|
+
fireEvent.click(screen.getByLabelText("Close"));
|
|
129
|
+
await expect(pending!).resolves.toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("the schema-less body gets media access bound to its document", async () => {
|
|
133
|
+
const api = makeApi();
|
|
134
|
+
const { seen, renderField } = makeSlot();
|
|
135
|
+
const entry: CmsEntry = { id: "a", path: "content/posts/a.md", title: "Alpha", body: "hello" };
|
|
136
|
+
renderWith(entry, api, renderField);
|
|
137
|
+
await screen.findByTestId("slot-control");
|
|
138
|
+
|
|
139
|
+
expect(seen.media).toBeDefined();
|
|
140
|
+
await seen.media!.resolve(["./hero.png"]);
|
|
141
|
+
expect(api.resolve).toHaveBeenCalledWith(["./hero.png"], "content/posts/a.md");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("without a media api the slot receives no media handle", async () => {
|
|
145
|
+
const { seen, renderField } = makeSlot();
|
|
146
|
+
const entry: CmsEntry = { id: "a", path: "content/posts/a.md", title: "Alpha", body: "", fields: [bodyField] };
|
|
147
|
+
renderWith(entry, undefined, renderField);
|
|
148
|
+
await screen.findByTestId("slot-control");
|
|
149
|
+
expect(seen.media).toBeUndefined();
|
|
150
|
+
});
|
|
@@ -183,3 +183,21 @@ describe("pathForStoreAs", () => {
|
|
|
183
183
|
expect(pathForStoreAs(manual, "public")).toBe("files/manual.pdf");
|
|
184
184
|
});
|
|
185
185
|
});
|
|
186
|
+
|
|
187
|
+
// The gap this closes: the picker row asked the preview for its compact form,
|
|
188
|
+
// and compact rendered the file chip for every kind — so an image showed as a
|
|
189
|
+
// filename squeezed into a 48px square instead of a thumbnail.
|
|
190
|
+
test("the picker shows a thumbnail for images and an icon for other kinds", async () => {
|
|
191
|
+
const api = makeApi();
|
|
192
|
+
renderField(<MediaField shape="string" value="" onChange={jest.fn()} set="uploads" />, api);
|
|
193
|
+
|
|
194
|
+
fireEvent.click(screen.getByTestId("media-choose"));
|
|
195
|
+
await screen.findByTestId(`media-row-${hero.id}`);
|
|
196
|
+
|
|
197
|
+
const thumb = screen.getByTestId(`media-thumb-${hero.id}`);
|
|
198
|
+
expect(thumb.tagName).toBe("IMG");
|
|
199
|
+
expect(thumb).toHaveAttribute("src", hero.url);
|
|
200
|
+
// The PDF row has no thumbnail: it gets the kind icon instead.
|
|
201
|
+
expect(screen.queryByTestId(`media-thumb-${manual.id}`)).not.toBeInTheDocument();
|
|
202
|
+
expect(screen.getByTestId(`media-row-${manual.id}`)).toBeInTheDocument();
|
|
203
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
3
|
+
import { ThemeProvider } from "../ThemeProvider";
|
|
4
|
+
import { ProtectedBranchModal } from "../components/ProtectedBranchModal";
|
|
5
|
+
|
|
6
|
+
const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
|
|
7
|
+
|
|
8
|
+
const base = { branch: "main", onCreate: () => {}, onCancel: () => {} };
|
|
9
|
+
|
|
10
|
+
test("it names the branch, the document, and offers the one remedy", () => {
|
|
11
|
+
render(wrap(<ProtectedBranchModal {...base} documentLabel="Hello world" />));
|
|
12
|
+
|
|
13
|
+
const modal = screen.getByTestId("protected-branch-modal");
|
|
14
|
+
expect(modal).toHaveTextContent("This branch is protected");
|
|
15
|
+
expect(modal).toHaveTextContent("main");
|
|
16
|
+
expect(modal).toHaveTextContent("Hello world");
|
|
17
|
+
expect(modal).toHaveTextContent("Branched from main");
|
|
18
|
+
expect(screen.getByTestId("protected-branch-submit")).toHaveTextContent("Create branch and save");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("the suggested name prefills the field and submits", () => {
|
|
22
|
+
const onCreate = jest.fn();
|
|
23
|
+
render(wrap(<ProtectedBranchModal {...base} suggestedName="edit/hello" onCreate={onCreate} />));
|
|
24
|
+
|
|
25
|
+
expect(screen.getByTestId("protected-branch-name")).toHaveValue("edit/hello");
|
|
26
|
+
fireEvent.click(screen.getByTestId("protected-branch-submit"));
|
|
27
|
+
expect(onCreate).toHaveBeenCalledWith("edit/hello");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// The author's own name wins: a suggestion arriving late (the host resolves the
|
|
31
|
+
// document a render after the modal opens) must not overwrite what they typed.
|
|
32
|
+
test("a late suggestion does not overwrite a typed name", () => {
|
|
33
|
+
const { rerender } = render(wrap(<ProtectedBranchModal {...base} />));
|
|
34
|
+
fireEvent.change(screen.getByTestId("protected-branch-name"), { target: { value: "mine" } });
|
|
35
|
+
rerender(wrap(<ProtectedBranchModal {...base} suggestedName="edit/hello" />));
|
|
36
|
+
expect(screen.getByTestId("protected-branch-name")).toHaveValue("mine");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("an empty name cannot be submitted", () => {
|
|
40
|
+
const onCreate = jest.fn();
|
|
41
|
+
render(wrap(<ProtectedBranchModal {...base} onCreate={onCreate} />));
|
|
42
|
+
fireEvent.click(screen.getByTestId("protected-branch-submit"));
|
|
43
|
+
expect(onCreate).not.toHaveBeenCalled();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// While the branch is being created there is no way out but forward: cancelling
|
|
47
|
+
// mid-flight would abandon a save whose branch may already exist.
|
|
48
|
+
test("busy disables both actions and hides the close control", () => {
|
|
49
|
+
const onCreate = jest.fn();
|
|
50
|
+
render(wrap(<ProtectedBranchModal {...base} suggestedName="edit/hello" busy onCreate={onCreate} />));
|
|
51
|
+
|
|
52
|
+
expect(screen.getByTestId("protected-branch-submit")).toHaveTextContent("Creating…");
|
|
53
|
+
expect(screen.queryByTestId("protected-branch-close")).not.toBeInTheDocument();
|
|
54
|
+
fireEvent.click(screen.getByTestId("protected-branch-submit"));
|
|
55
|
+
expect(onCreate).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("the server's message is shown so a rejected name can be corrected", () => {
|
|
59
|
+
render(wrap(<ProtectedBranchModal {...base} error={`branch "edit/hello" already exists`} />));
|
|
60
|
+
expect(screen.getByTestId("protected-branch-error")).toHaveTextContent("already exists");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("cancel dismisses", () => {
|
|
64
|
+
const onCancel = jest.fn();
|
|
65
|
+
render(wrap(<ProtectedBranchModal {...base} onCancel={onCancel} />));
|
|
66
|
+
fireEvent.click(screen.getByTestId("protected-branch-cancel"));
|
|
67
|
+
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
68
|
+
});
|