@gogitcms/design-system 0.15.0-next.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -0
- package/css/components.css +312 -0
- package/css/tokens.css +212 -0
- package/package.json +65 -0
- package/src/ThemeProvider.tsx +86 -0
- package/src/__tests__/ApplyChangesModal.test.tsx +148 -0
- package/src/__tests__/BranchImport.test.tsx +46 -0
- package/src/__tests__/Button.test.tsx +45 -0
- package/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
- package/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
- package/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
- package/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
- package/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
- package/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
- package/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
- package/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
- package/src/__tests__/ContentBrowser.media.test.tsx +140 -0
- package/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
- package/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
- package/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
- package/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
- package/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
- package/src/__tests__/ContentBrowser.search.test.tsx +135 -0
- package/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
- package/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
- package/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
- package/src/__tests__/MediaBrowser.test.tsx +353 -0
- package/src/__tests__/MediaField.test.tsx +185 -0
- package/src/__tests__/Notifications.test.tsx +69 -0
- package/src/__tests__/Onboarding.test.tsx +287 -0
- package/src/__tests__/cssTokens.test.ts +201 -0
- package/src/__tests__/fieldComponents.test.ts +43 -0
- package/src/__tests__/reorder.test.ts +58 -0
- package/src/components/ApplyChangesModal.tsx +348 -0
- package/src/components/BranchImport.tsx +157 -0
- package/src/components/BranchMenu.tsx +192 -0
- package/src/components/Button.tsx +131 -0
- package/src/components/ChangeDetail.tsx +472 -0
- package/src/components/ChangeRequestSummary.tsx +173 -0
- package/src/components/CollabField.tsx +388 -0
- package/src/components/ContentBrowser.tsx +5073 -0
- package/src/components/Icon.tsx +28 -0
- package/src/components/Icon.web.tsx +31 -0
- package/src/components/Input.tsx +106 -0
- package/src/components/MediaBrowser.tsx +766 -0
- package/src/components/MediaField.tsx +670 -0
- package/src/components/MediaPreview.tsx +91 -0
- package/src/components/MediaPreview.web.tsx +169 -0
- package/src/components/NavRow.tsx +105 -0
- package/src/components/Notifications.tsx +301 -0
- package/src/components/Onboarding.tsx +751 -0
- package/src/components/ProjectMenu.tsx +124 -0
- package/src/components/Segment.tsx +87 -0
- package/src/components/Skeleton.tsx +216 -0
- package/src/components/Spinner.tsx +44 -0
- package/src/components/Text.tsx +85 -0
- package/src/components/documentDrafts.ts +213 -0
- package/src/components/layout.tsx +284 -0
- package/src/components/primitives.tsx +143 -0
- package/src/components/reorder.ts +40 -0
- package/src/fieldComponents.ts +63 -0
- package/src/icons.ts +102 -0
- package/src/index.ts +198 -0
- package/src/media.ts +229 -0
- package/src/theme.ts +116 -0
- package/src/web/Button.tsx +110 -0
- package/src/web/Icon.tsx +52 -0
- package/src/web/Input.tsx +39 -0
- package/src/web/index.ts +43 -0
- package/src/web/primitives.tsx +119 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// A collaborator's edit has to reach everything a local edit reaches.
|
|
2
|
+
//
|
|
3
|
+
// The controls bind straight to the CRDT, so a peer's change always *rendered*
|
|
4
|
+
// correctly — and told the host nothing. Two things depended on being told: the
|
|
5
|
+
// draft channel that feeds the preview (so a preview only ever rebuilt for
|
|
6
|
+
// whoever typed) and the value map a save serializes (so the next save wrote the
|
|
7
|
+
// field back as it was before the peer touched it, losing their work with both
|
|
8
|
+
// screens showing the newer text).
|
|
9
|
+
|
|
10
|
+
import React from "react";
|
|
11
|
+
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
12
|
+
import { ThemeProvider } from "../ThemeProvider";
|
|
13
|
+
import {
|
|
14
|
+
ContentBrowser,
|
|
15
|
+
type CmsEntry,
|
|
16
|
+
type CmsNavSection,
|
|
17
|
+
type CollabApi,
|
|
18
|
+
type CollabRegister,
|
|
19
|
+
type CollabText,
|
|
20
|
+
type OpenTab,
|
|
21
|
+
} from "../components/ContentBrowser";
|
|
22
|
+
import { readDraft, refreshDrafts } from "../components/documentDrafts";
|
|
23
|
+
|
|
24
|
+
jest.mock("../ThemeProvider", () => {
|
|
25
|
+
const actual = jest.requireActual("../ThemeProvider");
|
|
26
|
+
return { ...actual, useResponsive: () => ({ width: 1300, height: 900, isDesktop: true, isMobile: false }) };
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* An in-memory stand-in for @gogitcms/realtime's DocCollab.
|
|
33
|
+
*
|
|
34
|
+
* `remote` is the whole point: it writes a value the way a peer's update does —
|
|
35
|
+
* into the shared store, notifying subscribers, without going near the control's
|
|
36
|
+
* own onChange. That is exactly the path that used to end at the rendered input.
|
|
37
|
+
*/
|
|
38
|
+
function fakeCollab(seed: Record<string, string> = {}) {
|
|
39
|
+
const values = new Map<string, unknown>(Object.entries(seed));
|
|
40
|
+
const listeners = new Map<string, Set<() => void>>();
|
|
41
|
+
// Every snapshot getter below has to return a stable value for an unchanged
|
|
42
|
+
// store — useSyncExternalStore re-renders until it does. The real DocCollab
|
|
43
|
+
// memoizes its bindings per path and hands out one frozen empty peer list;
|
|
44
|
+
// a fake that allocates per call loops instead of testing anything.
|
|
45
|
+
const noPeers: never[] = [];
|
|
46
|
+
|
|
47
|
+
const notify = (path: string) => listeners.get(path)?.forEach((l) => l());
|
|
48
|
+
const listen = (path: string, cb: () => void) => {
|
|
49
|
+
let set = listeners.get(path);
|
|
50
|
+
if (!set) listeners.set(path, (set = new Set()));
|
|
51
|
+
set.add(cb);
|
|
52
|
+
return () => void set!.delete(cb);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const textCache = new Map<string, CollabText>();
|
|
56
|
+
const text = (path: string): CollabText => {
|
|
57
|
+
let binding = textCache.get(path);
|
|
58
|
+
if (!binding) {
|
|
59
|
+
textCache.set(path, (binding = {
|
|
60
|
+
get: () => String(values.get(path) ?? ""),
|
|
61
|
+
set: (next) => {
|
|
62
|
+
values.set(path, next);
|
|
63
|
+
notify(path);
|
|
64
|
+
},
|
|
65
|
+
peers: () => noPeers,
|
|
66
|
+
subscribe: (cb) => listen(path, cb),
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
return binding;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const regCache = new Map<string, CollabRegister>();
|
|
73
|
+
const register = (path: string): CollabRegister => {
|
|
74
|
+
let binding = regCache.get(path);
|
|
75
|
+
if (!binding) {
|
|
76
|
+
regCache.set(path, (binding = {
|
|
77
|
+
get: () => values.get(path),
|
|
78
|
+
set: (next) => {
|
|
79
|
+
values.set(path, next);
|
|
80
|
+
notify(path);
|
|
81
|
+
},
|
|
82
|
+
subscribe: (cb) => listen(path, cb),
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
return binding;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const api: CollabApi = {
|
|
89
|
+
text,
|
|
90
|
+
register,
|
|
91
|
+
setFocus: () => {},
|
|
92
|
+
peersAt: () => noPeers,
|
|
93
|
+
peersUnder: () => noPeers,
|
|
94
|
+
participants: () => noPeers,
|
|
95
|
+
awarenessVersion: () => 0,
|
|
96
|
+
subscribeAwareness: () => () => {},
|
|
97
|
+
ydoc: {},
|
|
98
|
+
awareness: {},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
collab: api,
|
|
103
|
+
/** One peer's change, or several in a single transaction. */
|
|
104
|
+
remote(next: Record<string, unknown>) {
|
|
105
|
+
act(() => {
|
|
106
|
+
for (const [path, value] of Object.entries(next)) {
|
|
107
|
+
values.set(path, value);
|
|
108
|
+
notify(path);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const sections: CmsNavSection[] = [
|
|
116
|
+
{ title: "Content", items: [{ key: "posts", label: "Posts", icon: "newspaper" }] },
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
const base = {
|
|
120
|
+
workspace: { name: "acme/site", initials: "AC", branch: "main", changed: 0 },
|
|
121
|
+
sections,
|
|
122
|
+
activeNavKey: "posts",
|
|
123
|
+
onSelectNav: () => {},
|
|
124
|
+
entries: [{ id: "a", path: "posts/a.md", title: "Alpha", body: "", fields: [] } as CmsEntry],
|
|
125
|
+
userInitials: "ED",
|
|
126
|
+
} as const;
|
|
127
|
+
|
|
128
|
+
function docWith(collab: CollabApi, extra: CmsEntry["fields"] = []): CmsEntry {
|
|
129
|
+
return {
|
|
130
|
+
id: "a",
|
|
131
|
+
path: "posts/a.md",
|
|
132
|
+
title: "Alpha",
|
|
133
|
+
body: "hello",
|
|
134
|
+
collab,
|
|
135
|
+
fields: [
|
|
136
|
+
{ name: "title", type: "string", label: "Title", value: "Alpha" },
|
|
137
|
+
...(extra ?? []),
|
|
138
|
+
{ name: "content", type: "string", source: "body", value: "hello" },
|
|
139
|
+
],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
describe("a collaborator's edit", () => {
|
|
144
|
+
beforeEach(() => jest.useFakeTimers());
|
|
145
|
+
afterEach(() => jest.useRealTimers());
|
|
146
|
+
|
|
147
|
+
it("reaches the draft channel, so every viewer's preview rebuilds", () => {
|
|
148
|
+
const { collab, remote } = fakeCollab({ title: "Alpha" });
|
|
149
|
+
const onEntryDraft = jest.fn();
|
|
150
|
+
render(
|
|
151
|
+
wrap(
|
|
152
|
+
<ContentBrowser
|
|
153
|
+
{...base}
|
|
154
|
+
openTabs={[{ id: "a", collection: "posts", entry: docWith(collab) } as OpenTab]}
|
|
155
|
+
activeTabId="a"
|
|
156
|
+
onEntryDraft={onEntryDraft}
|
|
157
|
+
/>
|
|
158
|
+
)
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// Mounting in sync says nothing: the host seeded its map from this document,
|
|
162
|
+
// and an edit event here would mark an untouched document unsaved.
|
|
163
|
+
act(() => void jest.advanceTimersByTime(500));
|
|
164
|
+
expect(onEntryDraft).not.toHaveBeenCalled();
|
|
165
|
+
|
|
166
|
+
remote({ title: "Alpha, from the other tab" });
|
|
167
|
+
act(() => void jest.advanceTimersByTime(500));
|
|
168
|
+
|
|
169
|
+
expect(onEntryDraft).toHaveBeenCalledTimes(1);
|
|
170
|
+
expect(onEntryDraft.mock.calls[0][0]).toEqual({
|
|
171
|
+
id: "a",
|
|
172
|
+
fields: { title: "Alpha, from the other tab" },
|
|
173
|
+
body: "hello",
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("is what the next save writes, rather than the value it replaced", async () => {
|
|
178
|
+
const { collab, remote } = fakeCollab({ title: "Alpha" });
|
|
179
|
+
const onSaveEntry = jest.fn();
|
|
180
|
+
render(
|
|
181
|
+
wrap(
|
|
182
|
+
<ContentBrowser
|
|
183
|
+
{...base}
|
|
184
|
+
openTabs={[{ id: "a", collection: "posts", entry: docWith(collab) } as OpenTab]}
|
|
185
|
+
activeTabId="a"
|
|
186
|
+
onSaveEntry={onSaveEntry}
|
|
187
|
+
onEntryDraft={jest.fn()}
|
|
188
|
+
/>
|
|
189
|
+
)
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
remote({ title: "Alpha, from the other tab" });
|
|
193
|
+
act(() => void jest.advanceTimersByTime(500));
|
|
194
|
+
|
|
195
|
+
// Save enables once the document is dirty — which a peer's edit makes it,
|
|
196
|
+
// because the document has genuinely diverged from disk.
|
|
197
|
+
act(() => void fireEvent.click(screen.getByTestId("save-entry")));
|
|
198
|
+
|
|
199
|
+
expect(onSaveEntry).toHaveBeenCalledTimes(1);
|
|
200
|
+
expect(onSaveEntry.mock.calls[0][0].fields).toEqual({ title: "Alpha, from the other tab" });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("survives arriving alongside others in one transaction", () => {
|
|
204
|
+
// Several fields in a single update — a peer pasting across a form, and what
|
|
205
|
+
// a document's first sync looks like. They re-render together and commit in
|
|
206
|
+
// one flush, so an edit that spread the map its own render closed over would
|
|
207
|
+
// keep the last field and drop the rest.
|
|
208
|
+
const { collab, remote } = fakeCollab({ title: "Alpha", subtitle: "Beta", author: "Cass" });
|
|
209
|
+
const onEntryDraft = jest.fn();
|
|
210
|
+
render(
|
|
211
|
+
wrap(
|
|
212
|
+
<ContentBrowser
|
|
213
|
+
{...base}
|
|
214
|
+
openTabs={[
|
|
215
|
+
{
|
|
216
|
+
id: "a",
|
|
217
|
+
collection: "posts",
|
|
218
|
+
entry: docWith(collab, [
|
|
219
|
+
{ name: "subtitle", type: "string", label: "Subtitle", value: "Beta" },
|
|
220
|
+
{ name: "author", type: "string", label: "Author", value: "Cass" },
|
|
221
|
+
]),
|
|
222
|
+
} as OpenTab,
|
|
223
|
+
]}
|
|
224
|
+
activeTabId="a"
|
|
225
|
+
onEntryDraft={onEntryDraft}
|
|
226
|
+
/>
|
|
227
|
+
)
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
remote({ title: "one", subtitle: "two", author: "three" });
|
|
231
|
+
act(() => void jest.advanceTimersByTime(500));
|
|
232
|
+
|
|
233
|
+
const last = onEntryDraft.mock.calls[onEntryDraft.mock.calls.length - 1][0];
|
|
234
|
+
expect(last.fields).toEqual({ title: "one", subtitle: "two", author: "three" });
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("does not make a local keystroke arrive twice", () => {
|
|
238
|
+
// The mirror settles the value on its way out, so the CRDT echo of the
|
|
239
|
+
// user's own typing is not read back as somebody else's change.
|
|
240
|
+
const { collab } = fakeCollab({ title: "Alpha" });
|
|
241
|
+
const onEntryDraft = jest.fn();
|
|
242
|
+
render(
|
|
243
|
+
wrap(
|
|
244
|
+
<ContentBrowser
|
|
245
|
+
{...base}
|
|
246
|
+
openTabs={[{ id: "a", collection: "posts", entry: docWith(collab) } as OpenTab]}
|
|
247
|
+
activeTabId="a"
|
|
248
|
+
onEntryDraft={onEntryDraft}
|
|
249
|
+
/>
|
|
250
|
+
)
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const input = screen.getAllByRole("textbox")[0];
|
|
254
|
+
act(() => void fireEvent.change(input, { target: { value: "Alpha edited" } }));
|
|
255
|
+
act(() => void jest.advanceTimersByTime(500));
|
|
256
|
+
|
|
257
|
+
expect(onEntryDraft).toHaveBeenCalledTimes(1);
|
|
258
|
+
expect(onEntryDraft.mock.calls[0][0].fields).toEqual({ title: "Alpha edited" });
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// A collaborative field is bound to the CRDT, and the CRDT is empty until the
|
|
263
|
+
// provider's first sync arrives. So every field on the page changes shortly
|
|
264
|
+
// after mount — from nothing to the value the server already had — and a save
|
|
265
|
+
// state set by "something changed" reported an untouched document as unsaved
|
|
266
|
+
// the moment it opened. The author had a Discard button and an unsaved badge
|
|
267
|
+
// for work nobody had done.
|
|
268
|
+
describe("the first sync of a document", () => {
|
|
269
|
+
beforeEach(() => {
|
|
270
|
+
jest.useFakeTimers();
|
|
271
|
+
localStorage.clear();
|
|
272
|
+
refreshDrafts();
|
|
273
|
+
});
|
|
274
|
+
afterEach(() => jest.useRealTimers());
|
|
275
|
+
|
|
276
|
+
it("leaves the document clean, because nothing has changed from what is saved", () => {
|
|
277
|
+
// Seeded empty: the room has not synced yet, which is what every load looks
|
|
278
|
+
// like for the moment before the socket answers.
|
|
279
|
+
const { collab, remote } = fakeCollab();
|
|
280
|
+
render(
|
|
281
|
+
wrap(
|
|
282
|
+
<ContentBrowser
|
|
283
|
+
{...base}
|
|
284
|
+
openTabs={[{ id: "a", collection: "posts", entry: docWith(collab) } as OpenTab]}
|
|
285
|
+
activeTabId="a"
|
|
286
|
+
onSaveEntry={jest.fn()}
|
|
287
|
+
/>
|
|
288
|
+
)
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
// The sync lands, carrying the document the server already has.
|
|
292
|
+
remote({ title: "Alpha", content: "hello" });
|
|
293
|
+
act(() => void jest.advanceTimersByTime(1000));
|
|
294
|
+
|
|
295
|
+
expect(screen.queryByTestId("autosave-indicator")).not.toBeInTheDocument();
|
|
296
|
+
expect(screen.queryByTestId("discard-changes")).not.toBeInTheDocument();
|
|
297
|
+
expect(screen.getByTestId("save-entry")).toBeDisabled();
|
|
298
|
+
expect(readDraft("a")).toBeNull();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("still leaves a peer's genuine edit dirty", () => {
|
|
302
|
+
// The guard is a value comparison, not a window of silence after mount: an
|
|
303
|
+
// edit that arrives in the same breath as the sync is still an edit.
|
|
304
|
+
const { collab, remote } = fakeCollab();
|
|
305
|
+
render(
|
|
306
|
+
wrap(
|
|
307
|
+
<ContentBrowser
|
|
308
|
+
{...base}
|
|
309
|
+
openTabs={[{ id: "a", collection: "posts", entry: docWith(collab) } as OpenTab]}
|
|
310
|
+
activeTabId="a"
|
|
311
|
+
onSaveEntry={jest.fn()}
|
|
312
|
+
/>
|
|
313
|
+
)
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
remote({ title: "Alpha, from the other tab", content: "hello" });
|
|
317
|
+
act(() => void jest.advanceTimersByTime(1000));
|
|
318
|
+
|
|
319
|
+
expect(screen.getByTestId("autosave-indicator")).toHaveTextContent("Unsaved changes");
|
|
320
|
+
expect(screen.getByTestId("save-entry")).not.toBeDisabled();
|
|
321
|
+
});
|
|
322
|
+
});
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// The window between a field mounting and the shared document arriving.
|
|
2
|
+
//
|
|
3
|
+
// Every collab-bound control read its binding in that window and got nothing —
|
|
4
|
+
// not because the field was empty but because the room had yet to answer — so a
|
|
5
|
+
// saved document rendered blank for as long as the handshake took. And an edit
|
|
6
|
+
// made in that moment went into the doc *ahead* of the server's seeded value,
|
|
7
|
+
// where a Y.Text does not replace but merges: choosing "draft" in a field that
|
|
8
|
+
// turns out to hold "shipped" left "shippeddraft" behind in the document.
|
|
9
|
+
//
|
|
10
|
+
// The fake below is built to catch exactly that second one — its `sync` inserts
|
|
11
|
+
// the seed alongside whatever the client has written, the way two concurrent
|
|
12
|
+
// inserts at position 0 actually converge.
|
|
13
|
+
|
|
14
|
+
import React from "react";
|
|
15
|
+
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
16
|
+
import { ThemeProvider } from "../ThemeProvider";
|
|
17
|
+
import {
|
|
18
|
+
ContentBrowser,
|
|
19
|
+
type CmsEntry,
|
|
20
|
+
type CmsNavSection,
|
|
21
|
+
type CollabApi,
|
|
22
|
+
type CollabRegister,
|
|
23
|
+
type CollabText,
|
|
24
|
+
type EntryField,
|
|
25
|
+
type OpenTab,
|
|
26
|
+
} from "../components/ContentBrowser";
|
|
27
|
+
import { refreshDrafts } from "../components/documentDrafts";
|
|
28
|
+
|
|
29
|
+
jest.mock("../ThemeProvider", () => {
|
|
30
|
+
const actual = jest.requireActual("../ThemeProvider");
|
|
31
|
+
return { ...actual, useResponsive: () => ({ width: 1300, height: 900, isDesktop: true, isMobile: false }) };
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
|
|
35
|
+
|
|
36
|
+
function fakeCollab() {
|
|
37
|
+
const texts = new Map<string, string>();
|
|
38
|
+
const regs = new Map<string, unknown>();
|
|
39
|
+
const listeners = new Map<string, Set<() => void>>();
|
|
40
|
+
const syncListeners = new Set<() => void>();
|
|
41
|
+
let synced = false;
|
|
42
|
+
const noPeers: never[] = [];
|
|
43
|
+
|
|
44
|
+
const notify = (path: string) => listeners.get(path)?.forEach((l) => l());
|
|
45
|
+
const listen = (path: string, cb: () => void) => {
|
|
46
|
+
let set = listeners.get(path);
|
|
47
|
+
if (!set) listeners.set(path, (set = new Set()));
|
|
48
|
+
set.add(cb);
|
|
49
|
+
return () => void set!.delete(cb);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const textCache = new Map<string, CollabText>();
|
|
53
|
+
const text = (path: string): CollabText => {
|
|
54
|
+
let binding = textCache.get(path);
|
|
55
|
+
if (!binding) {
|
|
56
|
+
textCache.set(path, (binding = {
|
|
57
|
+
get: () => texts.get(path) ?? "",
|
|
58
|
+
set: (next) => { texts.set(path, next); notify(path); },
|
|
59
|
+
peers: () => noPeers,
|
|
60
|
+
subscribe: (cb) => listen(path, cb),
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
return binding;
|
|
64
|
+
};
|
|
65
|
+
const regCache = new Map<string, CollabRegister>();
|
|
66
|
+
const register = (path: string): CollabRegister => {
|
|
67
|
+
let binding = regCache.get(path);
|
|
68
|
+
if (!binding) {
|
|
69
|
+
regCache.set(path, (binding = {
|
|
70
|
+
get: () => regs.get(path),
|
|
71
|
+
set: (next) => { regs.set(path, next); notify(path); },
|
|
72
|
+
subscribe: (cb) => listen(path, cb),
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
return binding;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const collab: CollabApi = {
|
|
79
|
+
text,
|
|
80
|
+
register,
|
|
81
|
+
setFocus: () => {},
|
|
82
|
+
peersAt: () => noPeers,
|
|
83
|
+
peersUnder: () => noPeers,
|
|
84
|
+
participants: () => noPeers,
|
|
85
|
+
awarenessVersion: () => 0,
|
|
86
|
+
subscribeAwareness: () => () => {},
|
|
87
|
+
synced: () => synced,
|
|
88
|
+
subscribeSynced: (cb) => {
|
|
89
|
+
syncListeners.add(cb);
|
|
90
|
+
return () => void syncListeners.delete(cb);
|
|
91
|
+
},
|
|
92
|
+
ydoc: {},
|
|
93
|
+
awareness: {},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
collab,
|
|
98
|
+
/**
|
|
99
|
+
* The server's seeded state arriving. A seeded string is *inserted*, not
|
|
100
|
+
* assigned: anything the client wrote first survives next to it, which is
|
|
101
|
+
* the merge that produced the corrupted values.
|
|
102
|
+
*/
|
|
103
|
+
sync(seed: { text?: Record<string, string>; reg?: Record<string, unknown> } = {}) {
|
|
104
|
+
act(() => {
|
|
105
|
+
synced = true;
|
|
106
|
+
for (const [p, v] of Object.entries(seed.text ?? {})) {
|
|
107
|
+
texts.set(p, v + (texts.get(p) ?? ""));
|
|
108
|
+
notify(p);
|
|
109
|
+
}
|
|
110
|
+
for (const [p, v] of Object.entries(seed.reg ?? {})) {
|
|
111
|
+
if (regs.get(p) === undefined) regs.set(p, v);
|
|
112
|
+
notify(p);
|
|
113
|
+
}
|
|
114
|
+
syncListeners.forEach((cb) => cb());
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
doc: {
|
|
118
|
+
text: (path: string) => texts.get(path),
|
|
119
|
+
reg: (path: string) => regs.get(path),
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const sections: CmsNavSection[] = [
|
|
125
|
+
{ title: "Content", items: [{ key: "posts", label: "Posts", icon: "newspaper" }] },
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
const base = {
|
|
129
|
+
workspace: { name: "acme/site", initials: "AC", branch: "main", changed: 0 },
|
|
130
|
+
sections,
|
|
131
|
+
activeNavKey: "posts",
|
|
132
|
+
onSelectNav: () => {},
|
|
133
|
+
entries: [{ id: "a", path: "posts/a.md", title: "Alpha", body: "", fields: [] } as CmsEntry],
|
|
134
|
+
userInitials: "ED",
|
|
135
|
+
} as const;
|
|
136
|
+
|
|
137
|
+
function renderDoc(fields: EntryField[], collab: CollabApi) {
|
|
138
|
+
const entry: CmsEntry = { id: "a", path: "posts/a.md", title: "Alpha", body: "", collab, fields };
|
|
139
|
+
return render(
|
|
140
|
+
wrap(
|
|
141
|
+
<ContentBrowser
|
|
142
|
+
{...base}
|
|
143
|
+
openTabs={[{ id: "a", collection: "posts", entry } as OpenTab]}
|
|
144
|
+
activeTabId="a"
|
|
145
|
+
onSaveEntry={jest.fn()}
|
|
146
|
+
/>
|
|
147
|
+
)
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// An edit in one test is a stored draft the next one would open the document
|
|
152
|
+
// with — every case here starts from the saved document.
|
|
153
|
+
beforeEach(() => {
|
|
154
|
+
localStorage.clear();
|
|
155
|
+
refreshDrafts();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const field = (over: Partial<EntryField>): EntryField =>
|
|
159
|
+
({ name: "f", label: "F", type: "string", value: "", ...over }) as EntryField;
|
|
160
|
+
|
|
161
|
+
describe("an edit made before the room has synced", () => {
|
|
162
|
+
it("replaces the seeded value rather than merging into it", () => {
|
|
163
|
+
const { collab, sync, doc } = fakeCollab();
|
|
164
|
+
renderDoc(
|
|
165
|
+
[field({ name: "status", type: "string", component: "select", enumValues: ["draft", "shipped"], value: "shipped" })],
|
|
166
|
+
collab
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
// The author changes their mind before the socket has finished its
|
|
170
|
+
// handshake. Nothing is written yet — there is no document to write to.
|
|
171
|
+
fireEvent.change(screen.getByTestId("select-control"), { target: { value: "draft" } });
|
|
172
|
+
expect(doc.text("status")).toBeUndefined();
|
|
173
|
+
expect((screen.getByTestId("select-control") as HTMLSelectElement).value).toBe("draft");
|
|
174
|
+
|
|
175
|
+
// The seed lands and the held edit goes through on top of it.
|
|
176
|
+
sync({ text: { status: "shipped" } });
|
|
177
|
+
expect(doc.text("status")).toBe("draft");
|
|
178
|
+
expect((screen.getByTestId("select-control") as HTMLSelectElement).value).toBe("draft");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("does the same for a text field, which is where it corrupted the value", () => {
|
|
182
|
+
const { collab, sync, doc } = fakeCollab();
|
|
183
|
+
renderDoc([field({ name: "title", type: "string", value: "Alpha" })], collab);
|
|
184
|
+
|
|
185
|
+
const input = screen.getByDisplayValue("Alpha");
|
|
186
|
+
fireEvent.change(input, { target: { value: "Beta" } });
|
|
187
|
+
expect(doc.text("title")).toBeUndefined();
|
|
188
|
+
|
|
189
|
+
sync({ text: { title: "Alpha" } });
|
|
190
|
+
// Not "AlphaBeta".
|
|
191
|
+
expect(doc.text("title")).toBe("Beta");
|
|
192
|
+
expect(screen.getByDisplayValue("Beta")).toBeInTheDocument();
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("before the room has synced, a field shows the document's own value", () => {
|
|
197
|
+
it("for a text input", () => {
|
|
198
|
+
const { collab } = fakeCollab();
|
|
199
|
+
renderDoc([field({ name: "title", type: "string", value: "Alpha" })], collab);
|
|
200
|
+
expect(screen.getByDisplayValue("Alpha")).toBeInTheDocument();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("for a textarea", () => {
|
|
204
|
+
const { collab } = fakeCollab();
|
|
205
|
+
renderDoc([field({ name: "summary", type: "string", component: "textarea", value: "In short." })], collab);
|
|
206
|
+
expect(screen.getByDisplayValue("In short.")).toBeInTheDocument();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("for a switch", () => {
|
|
210
|
+
const { collab } = fakeCollab();
|
|
211
|
+
renderDoc([field({ name: "featured", type: "boolean", component: "switch", value: true })], collab);
|
|
212
|
+
expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "true");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("for a stepper", () => {
|
|
216
|
+
const { collab } = fakeCollab();
|
|
217
|
+
renderDoc([field({ name: "count", type: "integer", component: "stepper", value: 7 })], collab);
|
|
218
|
+
expect(screen.getByDisplayValue("7")).toBeInTheDocument();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("for a date", () => {
|
|
222
|
+
const { collab } = fakeCollab();
|
|
223
|
+
renderDoc([field({ name: "publishedAt", type: "string", component: "date", value: "2026-03-04" })], collab);
|
|
224
|
+
expect(screen.getByTestId("date-control")).toHaveValue("2026-03-04");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("for a list, and never writes one into the room itself", () => {
|
|
228
|
+
const { collab, sync, doc } = fakeCollab();
|
|
229
|
+
renderDoc([field({ name: "tags", type: "array", component: "list", of: "string", value: ["a", "b"] })], collab);
|
|
230
|
+
|
|
231
|
+
expect(screen.getByTestId("list-remove-1")).toBeInTheDocument();
|
|
232
|
+
expect(doc.reg("tags")).toBeUndefined();
|
|
233
|
+
|
|
234
|
+
// Seeding a list is the server's job (collab's seedDoc). A client that did
|
|
235
|
+
// it wrote the empty list it had before its values loaded, and that empty
|
|
236
|
+
// list outlived the session in the room's snapshot.
|
|
237
|
+
sync();
|
|
238
|
+
expect(doc.reg("tags")).toBeUndefined();
|
|
239
|
+
expect(screen.getByTestId("list-remove-1")).toBeInTheDocument();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("once the room has arrived", () => {
|
|
244
|
+
it("a peer's value wins over the document's, including an emptied one", () => {
|
|
245
|
+
const { collab, sync } = fakeCollab();
|
|
246
|
+
renderDoc([field({ name: "title", type: "string", value: "Alpha" })], collab);
|
|
247
|
+
sync({ text: { title: "Alpha" } });
|
|
248
|
+
|
|
249
|
+
act(() => collab.text("title").set("Alpha, from the other tab"));
|
|
250
|
+
expect(screen.getByDisplayValue("Alpha, from the other tab")).toBeInTheDocument();
|
|
251
|
+
|
|
252
|
+
act(() => collab.text("title").set(""));
|
|
253
|
+
expect(screen.queryByDisplayValue("Alpha")).not.toBeInTheDocument();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("an edit goes straight to the document again", () => {
|
|
257
|
+
const { collab, sync, doc } = fakeCollab();
|
|
258
|
+
renderDoc([field({ name: "title", type: "string", value: "Alpha" })], collab);
|
|
259
|
+
sync({ text: { title: "Alpha" } });
|
|
260
|
+
|
|
261
|
+
fireEvent.change(screen.getByDisplayValue("Alpha"), { target: { value: "Beta" } });
|
|
262
|
+
expect(doc.text("title")).toBe("Beta");
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { Text } from "../components/Text";
|
|
4
|
+
import { ThemeProvider } from "../ThemeProvider";
|
|
5
|
+
import { ContentBrowser, type CmsEntry, type CmsNavSection } from "../components/ContentBrowser";
|
|
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 wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
|
|
14
|
+
|
|
15
|
+
const sections: CmsNavSection[] = [
|
|
16
|
+
{ title: "Content", items: [{ key: "posts", label: "Posts", icon: "newspaper" }] },
|
|
17
|
+
{ title: "Plugins", items: [{ key: "plugin:hello/hello", label: "Hello plugin", icon: "braces" }] },
|
|
18
|
+
];
|
|
19
|
+
const entries: CmsEntry[] = [{ id: "a", path: "posts/a.md", title: "Alpha", body: "" }];
|
|
20
|
+
|
|
21
|
+
const base = {
|
|
22
|
+
workspace: { name: "acme/site", initials: "AC", branch: "main", changed: 0 },
|
|
23
|
+
sections,
|
|
24
|
+
activeNavKey: "plugin:hello/hello",
|
|
25
|
+
onSelectNav: () => {},
|
|
26
|
+
entries,
|
|
27
|
+
userInitials: "ED",
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
test("contentSlot replaces the list/detail panes and keeps the sidebar", () => {
|
|
31
|
+
render(
|
|
32
|
+
wrap(
|
|
33
|
+
<ContentBrowser
|
|
34
|
+
{...base}
|
|
35
|
+
contentSlot={<Text testID="plugin-screen">Hello from a plugin</Text>}
|
|
36
|
+
/>
|
|
37
|
+
)
|
|
38
|
+
);
|
|
39
|
+
// The plugin pane renders the slot…
|
|
40
|
+
expect(screen.getByTestId("pane-plugin")).toBeInTheDocument();
|
|
41
|
+
expect(screen.getByTestId("plugin-screen")).toBeInTheDocument();
|
|
42
|
+
// …the sidebar (with the plugin link) stays…
|
|
43
|
+
expect(screen.getByText("Hello plugin")).toBeInTheDocument();
|
|
44
|
+
expect(screen.getByText("Posts")).toBeInTheDocument();
|
|
45
|
+
// …and the entry list/detail panes are gone.
|
|
46
|
+
expect(screen.queryByTestId("pane-entries")).not.toBeInTheDocument();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("without contentSlot the normal panes render", () => {
|
|
50
|
+
render(wrap(<ContentBrowser {...base} activeNavKey="posts" />));
|
|
51
|
+
expect(screen.queryByTestId("pane-plugin")).not.toBeInTheDocument();
|
|
52
|
+
expect(screen.getByTestId("pane-entries")).toBeInTheDocument();
|
|
53
|
+
});
|