@gogitcms/design-system 0.16.0-next.1 → 0.16.0-next.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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.references.test.tsx +179 -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 +494 -77
- package/src/components/DocumentDetails.tsx +0 -0
- 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/ReferenceField.tsx +0 -0
- package/src/components/primitives.tsx +46 -1
- package/src/fieldComponents.ts +2 -2
- package/src/history.ts +82 -0
- package/src/icons.ts +1 -0
- package/src/index.ts +28 -3
- package/src/media.ts +15 -0
- package/src/references.ts +97 -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,179 @@
|
|
|
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, type EntryField } from "../components/ContentBrowser";
|
|
5
|
+
import type { ReferenceApi, ReferenceDef, ReferenceTarget } from "../references";
|
|
6
|
+
|
|
7
|
+
// Force the desktop layout (jsdom reports width 0 → mobile otherwise).
|
|
8
|
+
jest.mock("../ThemeProvider", () => {
|
|
9
|
+
const actual = jest.requireActual("../ThemeProvider");
|
|
10
|
+
return { ...actual, useResponsive: () => ({ width: 1300, height: 900, isDesktop: true, isMobile: false }) };
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const ada: ReferenceTarget = { id: "au1", collection: "authors", path: "content/authors/ada.md", key: "content/authors/ada.md", label: "Ada Lovelace" };
|
|
14
|
+
const bob: ReferenceTarget = { id: "au2", collection: "authors", path: "content/authors/bob.md", key: "content/authors/bob.md", label: "Bob" };
|
|
15
|
+
const byKey: Record<string, ReferenceTarget> = { [ada.key]: ada, [bob.key]: bob };
|
|
16
|
+
|
|
17
|
+
function makeApi(over: Partial<ReferenceApi> = {}): ReferenceApi {
|
|
18
|
+
return {
|
|
19
|
+
search: jest.fn(async ({ query }) => [ada, bob].filter((t) => !query || t.label.toLowerCase().includes(query.toLowerCase()))),
|
|
20
|
+
resolve: jest.fn(async (_collections: string[], _key: string, keys: string[]) =>
|
|
21
|
+
keys.map((k) => ({ key: k, target: byKey[k] ?? null, ambiguous: false, candidates: [] })),
|
|
22
|
+
),
|
|
23
|
+
referrers: jest.fn(async () => [{ id: "d2", collection: "articles", path: "content/articles/other.md", label: "Other", fieldPath: "related.0" }]),
|
|
24
|
+
...over,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The editor keeps unsaved edits per document id in localStorage, and every
|
|
29
|
+
// test here renders document "a": clear it so one test's pick never seeds the
|
|
30
|
+
// next test's field.
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
window.localStorage.clear();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const sections: CmsNavSection[] = [
|
|
36
|
+
{ title: "Content", items: [{ key: "articles", label: "Articles", icon: "newspaper" }] },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const authorDef: ReferenceDef = { collections: ["authors"], key: "_path", keyName: "ref", embed: [], onDelete: "restrict" };
|
|
40
|
+
const embedDef: ReferenceDef = {
|
|
41
|
+
collections: ["authors"], key: "_path", keyName: "ref", onDelete: "restrict",
|
|
42
|
+
embed: [{ name: "name", source: "name" }, { name: "url", source: "/authors/{{slug}}/" }],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// `null` renders without a references seam; the default is a working one.
|
|
46
|
+
// (A default parameter also fires for an explicit `undefined`, so the no-seam
|
|
47
|
+
// case has to be spelled `null`.)
|
|
48
|
+
function renderWith(fields: EntryField[], api: ReferenceApi | null = makeApi()) {
|
|
49
|
+
const onSave = jest.fn();
|
|
50
|
+
const entry: CmsEntry = { id: "a", path: "content/articles/a.md", title: "Alpha", body: "", fields };
|
|
51
|
+
render(
|
|
52
|
+
<ThemeProvider>
|
|
53
|
+
<ContentBrowser
|
|
54
|
+
workspace={{ name: "acme/site", initials: "AC", branch: "main", changed: 0 }}
|
|
55
|
+
sections={sections}
|
|
56
|
+
activeNavKey="articles"
|
|
57
|
+
onSelectNav={() => {}}
|
|
58
|
+
entries={[entry]}
|
|
59
|
+
userInitials="ED"
|
|
60
|
+
references={api ?? undefined}
|
|
61
|
+
onSaveEntry={onSave}
|
|
62
|
+
/>
|
|
63
|
+
</ThemeProvider>,
|
|
64
|
+
);
|
|
65
|
+
return onSave;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test("a string reference resolves its key to the target's label", async () => {
|
|
69
|
+
const api = makeApi();
|
|
70
|
+
renderWith([{ name: "author", label: "Author", type: "string", component: "reference", reference: authorDef, value: ada.key }], api);
|
|
71
|
+
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
|
|
72
|
+
expect(screen.getByTestId("reference-key")).toHaveTextContent(ada.key);
|
|
73
|
+
expect(api.resolve).toHaveBeenCalledWith(["authors"], "_path", [ada.key]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a key naming nothing shows the missing state and keeps the key", async () => {
|
|
77
|
+
renderWith([{ name: "author", label: "Author", type: "string", component: "reference", reference: authorDef, value: "content/authors/gone.md" }]);
|
|
78
|
+
expect(await screen.findByTestId("reference-missing")).toBeInTheDocument();
|
|
79
|
+
expect(screen.getByTestId("reference-key")).toHaveTextContent("content/authors/gone.md");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("picking a document from the picker writes its key", async () => {
|
|
83
|
+
const api = makeApi();
|
|
84
|
+
renderWith([{ name: "author", label: "Author", type: "string", component: "reference", reference: authorDef, value: "" }], api);
|
|
85
|
+
expect(screen.getByText("No document selected")).toBeInTheDocument();
|
|
86
|
+
|
|
87
|
+
fireEvent.click(screen.getByTestId("reference-choose"));
|
|
88
|
+
fireEvent.click(await screen.findByTestId(`reference-row-${bob.id}`));
|
|
89
|
+
|
|
90
|
+
// The control now holds Bob's key and resolves it.
|
|
91
|
+
expect(await screen.findByText("Bob")).toBeInTheDocument();
|
|
92
|
+
expect(screen.getByTestId("reference-key")).toHaveTextContent(bob.key);
|
|
93
|
+
expect(api.search).toHaveBeenCalledWith(expect.objectContaining({ collections: ["authors"], key: "_path" }));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("an object reference shows its embedded copies read-only and picks by key child", async () => {
|
|
97
|
+
renderWith([
|
|
98
|
+
{
|
|
99
|
+
name: "author", label: "Author", type: "object", component: "reference", reference: embedDef,
|
|
100
|
+
fields: [
|
|
101
|
+
{ name: "ref", type: "string", value: undefined },
|
|
102
|
+
{ name: "name", label: "Name", type: "string", value: undefined },
|
|
103
|
+
{ name: "url", label: "URL", type: "string", value: undefined },
|
|
104
|
+
],
|
|
105
|
+
value: { ref: ada.key, name: "Ada Lovelace", url: "/authors/ada/" },
|
|
106
|
+
},
|
|
107
|
+
]);
|
|
108
|
+
expect(await screen.findByTestId("reference-target")).toHaveTextContent("Ada Lovelace");
|
|
109
|
+
const copies = screen.getByTestId("reference-copies");
|
|
110
|
+
expect(copies).toHaveTextContent("/authors/ada/");
|
|
111
|
+
expect(copies).toHaveTextContent("Copied from Ada Lovelace");
|
|
112
|
+
// No input is offered for a copy: the server owns them.
|
|
113
|
+
expect(screen.queryByDisplayValue("/authors/ada/")).not.toBeInTheDocument();
|
|
114
|
+
|
|
115
|
+
fireEvent.click(screen.getByTestId("reference-choose"));
|
|
116
|
+
fireEvent.click(await screen.findByTestId(`reference-row-${bob.id}`));
|
|
117
|
+
expect(screen.getByTestId("reference-key")).toHaveTextContent(bob.key);
|
|
118
|
+
// The old copies stay until the server replaces them on save; once the new
|
|
119
|
+
// key resolves the caption names the new target.
|
|
120
|
+
await waitFor(() => expect(screen.getByTestId("reference-copies")).toHaveTextContent("Copied from Bob"));
|
|
121
|
+
expect(screen.getByTestId("reference-copies")).toHaveTextContent("/authors/ada/");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("an array of references renders a picker per item", async () => {
|
|
125
|
+
renderWith([
|
|
126
|
+
{ name: "related", label: "Related", type: "array", of: "string", component: "list", reference: authorDef, value: [ada.key, bob.key] },
|
|
127
|
+
]);
|
|
128
|
+
expect(await screen.findAllByTestId("reference-choose")).toHaveLength(2);
|
|
129
|
+
expect(await screen.findByText("Bob")).toBeInTheDocument();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("the Details modal shows the document's metadata and its references both ways", async () => {
|
|
133
|
+
renderWith([
|
|
134
|
+
{ name: "title", label: "Title", type: "string", value: "Alpha" },
|
|
135
|
+
{ name: "author", label: "Author", type: "string", component: "reference", reference: authorDef, value: ada.key },
|
|
136
|
+
{ name: "editor", label: "Editor", type: "string", component: "reference", reference: authorDef, value: "content/authors/gone.md" },
|
|
137
|
+
]);
|
|
138
|
+
// Nothing about references sits in the document body itself.
|
|
139
|
+
await screen.findByText("Ada Lovelace");
|
|
140
|
+
expect(screen.queryByText("Referenced by")).not.toBeInTheDocument();
|
|
141
|
+
|
|
142
|
+
fireEvent.click(screen.getByTestId("detail-menu"));
|
|
143
|
+
fireEvent.click(screen.getByTestId("detail-details"));
|
|
144
|
+
const modal = await screen.findByTestId("document-details");
|
|
145
|
+
expect(modal).toHaveTextContent("content/articles/a.md");
|
|
146
|
+
expect(modal).toHaveTextContent("Articles");
|
|
147
|
+
|
|
148
|
+
const refs = await screen.findByTestId("details-references");
|
|
149
|
+
await waitFor(() => expect(refs).toHaveTextContent("Ada Lovelace"));
|
|
150
|
+
expect(refs).toHaveTextContent("author");
|
|
151
|
+
expect(refs).toHaveTextContent("content/authors/gone.md — missing");
|
|
152
|
+
|
|
153
|
+
const referrers = await screen.findByTestId("details-referrers");
|
|
154
|
+
await waitFor(() => expect(referrers).toHaveTextContent("Other"));
|
|
155
|
+
expect(referrers).toHaveTextContent("related.0");
|
|
156
|
+
|
|
157
|
+
fireEvent.click(screen.getByTestId("details-close"));
|
|
158
|
+
expect(screen.queryByTestId("document-details")).not.toBeInTheDocument();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("without a references seam the field renders read-only", async () => {
|
|
162
|
+
renderWith([{ name: "author", label: "Author", type: "string", component: "reference", reference: authorDef, value: ada.key }], null);
|
|
163
|
+
// Nothing resolves and the picker cannot open: the key shows raw.
|
|
164
|
+
expect(await screen.findByTestId("reference-key")).toHaveTextContent(ada.key);
|
|
165
|
+
fireEvent.click(screen.getByTestId("reference-choose"));
|
|
166
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
167
|
+
expect(screen.queryByText("Choose a document")).not.toBeInTheDocument();
|
|
168
|
+
expect(screen.queryByTestId("reference-target")).not.toBeInTheDocument();
|
|
169
|
+
expect(screen.queryByTestId("reference-missing")).not.toBeInTheDocument();
|
|
170
|
+
|
|
171
|
+
// Details still opens — it is a read — with the metadata and the raw key,
|
|
172
|
+
// but claims nothing about what the key resolves to.
|
|
173
|
+
fireEvent.click(screen.getByTestId("detail-menu"));
|
|
174
|
+
fireEvent.click(screen.getByTestId("detail-details"));
|
|
175
|
+
const modal = await screen.findByTestId("document-details");
|
|
176
|
+
expect(modal).toHaveTextContent("content/articles/a.md");
|
|
177
|
+
expect(screen.getByTestId("details-references")).toHaveTextContent(ada.key);
|
|
178
|
+
expect(screen.queryByTestId("details-referrers")).not.toBeInTheDocument();
|
|
179
|
+
});
|