@gogitcms/design-system 0.16.0-next.4 → 0.16.0-next.6
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__/ContentBrowser.history.test.tsx +209 -3
- package/src/__tests__/ContentBrowser.historycollab.test.tsx +433 -0
- package/src/components/ChangeDetail.tsx +84 -10
- package/src/components/CollabField.tsx +71 -0
- package/src/components/ContentBrowser.tsx +144 -38
- package/src/components/DocumentHistory.tsx +118 -2
- package/src/components/Notifications.tsx +146 -28
- package/src/components/primitives.tsx +46 -1
- package/src/icons.ts +1 -0
- package/src/index.ts +3 -2
|
@@ -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
|
+
});
|
|
@@ -5,7 +5,7 @@ import { Text } from "./Text";
|
|
|
5
5
|
import { Icon } from "./Icon";
|
|
6
6
|
import { Input } from "./Input";
|
|
7
7
|
import { Button } from "./Button";
|
|
8
|
-
import { Badge, DiffStat } from "./primitives";
|
|
8
|
+
import { Badge, CheckBox, DiffStat } from "./primitives";
|
|
9
9
|
|
|
10
10
|
/** How one field differs between the branch and its base. */
|
|
11
11
|
export type FieldChangeKind = "added" | "removed" | "changed" | "unchanged";
|
|
@@ -87,8 +87,29 @@ export type ChangeDetailProps = {
|
|
|
87
87
|
*/
|
|
88
88
|
conflicts?: FieldConflict[];
|
|
89
89
|
onResolveConflict?: (conflict: FieldConflict, choice: ConflictChoice, override?: unknown) => void;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Per-field selection, for a caller that does something with a subset of the
|
|
93
|
+
* diff — restoring fields from a past version, in the history pane.
|
|
94
|
+
*
|
|
95
|
+
* Only CHANGED fields get a box: an unchanged field's two sides are the same
|
|
96
|
+
* value, so selecting it could only ever be a no-op dressed up as a choice.
|
|
97
|
+
* The body is selectable under the name BODY_FIELD.
|
|
98
|
+
*
|
|
99
|
+
* Omitting onToggleField leaves the diff exactly as it was — a read-only
|
|
100
|
+
* comparison, which is what the changes surface wants.
|
|
101
|
+
*/
|
|
102
|
+
selectedFields?: readonly string[];
|
|
103
|
+
onToggleField?: (name: string) => void;
|
|
90
104
|
};
|
|
91
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The name the body answers to in `selectedFields` — the document body is a
|
|
108
|
+
* selectable "field" here but has no entry in `fields`, and a real frontmatter
|
|
109
|
+
* key could otherwise collide with whatever plain string we picked.
|
|
110
|
+
*/
|
|
111
|
+
export const BODY_FIELD = "\u0000body";
|
|
112
|
+
|
|
92
113
|
const STATUS_LABEL: Record<DocumentChange["status"], string> = {
|
|
93
114
|
A: "Added",
|
|
94
115
|
M: "Modified",
|
|
@@ -151,14 +172,29 @@ function ContextValue({ value }: { value: unknown }) {
|
|
|
151
172
|
);
|
|
152
173
|
}
|
|
153
174
|
|
|
154
|
-
function FieldRow({
|
|
175
|
+
function FieldRow({
|
|
176
|
+
field,
|
|
177
|
+
selected,
|
|
178
|
+
onToggle,
|
|
179
|
+
}: {
|
|
180
|
+
field: FieldChange;
|
|
181
|
+
// Both present or both absent: selection is offered only for a changed field
|
|
182
|
+
// whose caller wants it (see ChangeDetailProps.onToggleField).
|
|
183
|
+
selected?: boolean;
|
|
184
|
+
onToggle?: () => void;
|
|
185
|
+
}) {
|
|
155
186
|
const t = useTheme();
|
|
156
187
|
const changed = field.kind !== "unchanged";
|
|
157
188
|
return (
|
|
158
189
|
<View testID={`change-field-${field.name}`} style={{ gap: t.space(2) }}>
|
|
159
|
-
<
|
|
160
|
-
{
|
|
161
|
-
|
|
190
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
|
|
191
|
+
{onToggle ? (
|
|
192
|
+
<CheckBox value={!!selected} onToggle={onToggle} testID={`restore-field-${field.name}`} />
|
|
193
|
+
) : null}
|
|
194
|
+
<Text variant="label" color="tertiary">
|
|
195
|
+
{(field.label || field.name).toUpperCase()}
|
|
196
|
+
</Text>
|
|
197
|
+
</View>
|
|
162
198
|
{changed ? (
|
|
163
199
|
<View
|
|
164
200
|
style={{
|
|
@@ -294,7 +330,17 @@ function ConflictSide({ label, value, chosen }: { label: string; value: unknown;
|
|
|
294
330
|
* stacked blocks rather than a single line — the whole before and the whole
|
|
295
331
|
* after, each tinted, so a reviewer can read them.
|
|
296
332
|
*/
|
|
297
|
-
function BodyDiff({
|
|
333
|
+
function BodyDiff({
|
|
334
|
+
before,
|
|
335
|
+
after,
|
|
336
|
+
selected,
|
|
337
|
+
onToggle,
|
|
338
|
+
}: {
|
|
339
|
+
before?: string | null;
|
|
340
|
+
after?: string | null;
|
|
341
|
+
selected?: boolean;
|
|
342
|
+
onToggle?: () => void;
|
|
343
|
+
}) {
|
|
298
344
|
const t = useTheme();
|
|
299
345
|
if ((before ?? "") === (after ?? "")) {
|
|
300
346
|
if (!after) return null;
|
|
@@ -307,7 +353,12 @@ function BodyDiff({ before, after }: { before?: string | null; after?: string |
|
|
|
307
353
|
}
|
|
308
354
|
return (
|
|
309
355
|
<View testID="change-body" style={{ gap: t.space(2) }}>
|
|
310
|
-
<
|
|
356
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
|
|
357
|
+
{onToggle ? (
|
|
358
|
+
<CheckBox value={!!selected} onToggle={onToggle} testID="restore-field-body" />
|
|
359
|
+
) : null}
|
|
360
|
+
<Text variant="label" color="tertiary">BODY</Text>
|
|
361
|
+
</View>
|
|
311
362
|
<View
|
|
312
363
|
style={{
|
|
313
364
|
borderRadius: t.radius.md,
|
|
@@ -383,8 +434,21 @@ function AssetPreview({ preview }: { preview: ChangePreview }) {
|
|
|
383
434
|
* context. Strictly read-only — editing happens on the Edit surface, and this
|
|
384
435
|
* view describes a comparison rather than a document you can save.
|
|
385
436
|
*/
|
|
386
|
-
export function ChangeDetail({
|
|
437
|
+
export function ChangeDetail({
|
|
438
|
+
change,
|
|
439
|
+
loading,
|
|
440
|
+
emptyMessage = "Select a change",
|
|
441
|
+
conflicts,
|
|
442
|
+
onResolveConflict,
|
|
443
|
+
selectedFields,
|
|
444
|
+
onToggleField,
|
|
445
|
+
}: ChangeDetailProps) {
|
|
387
446
|
const t = useTheme();
|
|
447
|
+
const chosen = new Set(selectedFields ?? []);
|
|
448
|
+
// A field offers a box only when the caller wants selection AND there is
|
|
449
|
+
// something to select — an unchanged field's two sides are the same value.
|
|
450
|
+
const toggleFor = (name: string, changed: boolean) =>
|
|
451
|
+
onToggleField && changed ? () => onToggleField(name) : undefined;
|
|
388
452
|
|
|
389
453
|
// Conflicts keyed by field name (and a body flag), so each field can check for
|
|
390
454
|
// one as it renders. The body conflict is looked up separately.
|
|
@@ -454,7 +518,12 @@ export function ChangeDetail({ change, loading, emptyMessage = "Select a change"
|
|
|
454
518
|
onResolve={onResolveConflict ? (choice, override) => onResolveConflict(conflict, choice, override) : undefined}
|
|
455
519
|
/>
|
|
456
520
|
) : (
|
|
457
|
-
<FieldRow
|
|
521
|
+
<FieldRow
|
|
522
|
+
key={f.name}
|
|
523
|
+
field={f}
|
|
524
|
+
selected={chosen.has(f.name)}
|
|
525
|
+
onToggle={toggleFor(f.name, f.kind !== "unchanged")}
|
|
526
|
+
/>
|
|
458
527
|
);
|
|
459
528
|
})}
|
|
460
529
|
{/* Conflicts on fields the schema-ordered diff didn't surface (rare). */}
|
|
@@ -473,7 +542,12 @@ export function ChangeDetail({ change, loading, emptyMessage = "Select a change"
|
|
|
473
542
|
onResolve={onResolveConflict ? (choice, override) => onResolveConflict(bodyConflict!, choice, override) : undefined}
|
|
474
543
|
/>
|
|
475
544
|
) : (
|
|
476
|
-
<BodyDiff
|
|
545
|
+
<BodyDiff
|
|
546
|
+
before={change.bodyBefore}
|
|
547
|
+
after={change.bodyAfter}
|
|
548
|
+
selected={chosen.has(BODY_FIELD)}
|
|
549
|
+
onToggle={toggleFor(BODY_FIELD, (change.bodyBefore ?? "") !== (change.bodyAfter ?? ""))}
|
|
550
|
+
/>
|
|
477
551
|
)}
|
|
478
552
|
</ScrollView>
|
|
479
553
|
</View>
|
|
@@ -386,3 +386,74 @@ export function CollabTextarea({
|
|
|
386
386
|
</PresenceField>
|
|
387
387
|
);
|
|
388
388
|
}
|
|
389
|
+
|
|
390
|
+
// ---- writing a whole value into the shared document ------------------------
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Where one leaf of a document value lives in the shared doc.
|
|
394
|
+
*
|
|
395
|
+
* The split mirrors what the server seeds (collab.go's collectLeaves), because
|
|
396
|
+
* that is what decides which binding a control actually reads: a string is a
|
|
397
|
+
* Y.Text, and everything else — numbers, booleans, null, and whole lists — is a
|
|
398
|
+
* key in the "reg" map. Writing to the other one is not an error anyone sees;
|
|
399
|
+
* it is a value that silently lands where nothing is looking.
|
|
400
|
+
*/
|
|
401
|
+
type CollabLeaf = { kind: "text"; value: string } | { kind: "reg"; value: unknown };
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Flattens a value into the leaves the shared document holds it as, keyed by
|
|
405
|
+
* dotted path — `seo.title` for a group's child, exactly as the server does.
|
|
406
|
+
*
|
|
407
|
+
* `undefined` yields no leaves at all: it means the field is absent, which is
|
|
408
|
+
* expressed by clearing whatever was there rather than by writing something.
|
|
409
|
+
*/
|
|
410
|
+
function collabLeaves(path: string, value: unknown, into = new Map<string, CollabLeaf>()): Map<string, CollabLeaf> {
|
|
411
|
+
if (value === undefined) return into;
|
|
412
|
+
if (typeof value === "string") {
|
|
413
|
+
into.set(path, { kind: "text", value });
|
|
414
|
+
return into;
|
|
415
|
+
}
|
|
416
|
+
// A list is a whole-value register, matching CollabArrayField — its items are
|
|
417
|
+
// not addressable leaves, so it is written and cleared in one piece.
|
|
418
|
+
if (Array.isArray(value) || value === null || typeof value !== "object") {
|
|
419
|
+
into.set(path, { kind: "reg", value });
|
|
420
|
+
return into;
|
|
421
|
+
}
|
|
422
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
423
|
+
collabLeaves(path ? `${path}.${k}` : k, v, into);
|
|
424
|
+
}
|
|
425
|
+
return into;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Replaces a field's value in the shared document, so a value written by
|
|
430
|
+
* something other than typing — restoring a field from a past version — reaches
|
|
431
|
+
* the room instead of only the local form.
|
|
432
|
+
*
|
|
433
|
+
* In a live session the shared document is the source of truth: every collab
|
|
434
|
+
* control reads its binding in preference to the host's value, so a write that
|
|
435
|
+
* only touches local state is displayed for an instant and then overwritten by
|
|
436
|
+
* the CRDT's unchanged value. It has to be written here to be written at all.
|
|
437
|
+
*
|
|
438
|
+
* `previous` is what the field held, and is needed rather than inferable: the
|
|
439
|
+
* new value's leaves say what to write, but only the old value's leaves say
|
|
440
|
+
* what to CLEAR — a group that loses a child, or a field being cleared outright,
|
|
441
|
+
* would otherwise keep the shared value nobody can see any more.
|
|
442
|
+
*/
|
|
443
|
+
export function writeCollabValue(collab: CollabApi, path: string, previous: unknown, next: unknown): void {
|
|
444
|
+
const before = collabLeaves(path, previous);
|
|
445
|
+
const after = collabLeaves(path, next);
|
|
446
|
+
|
|
447
|
+
for (const [leafPath, leaf] of before) {
|
|
448
|
+
if (after.has(leafPath)) continue;
|
|
449
|
+
// Clearing is per shared type: a Y.Text empties, a register goes back to
|
|
450
|
+
// unset (which is what an absent key reads as, and what makes the control
|
|
451
|
+
// fall back to the document's own value).
|
|
452
|
+
if (leaf.kind === "text") collab.text(leafPath).set("");
|
|
453
|
+
else collab.register(leafPath).set(undefined);
|
|
454
|
+
}
|
|
455
|
+
for (const [leafPath, leaf] of after) {
|
|
456
|
+
if (leaf.kind === "text") collab.text(leafPath).set(leaf.value);
|
|
457
|
+
else collab.register(leafPath).set(leaf.value);
|
|
458
|
+
}
|
|
459
|
+
}
|