@gogitcms/editor 0.25.0 → 0.28.0
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/app/src/App.tsx +163 -1
- package/app/src/forms.ts +77 -0
- package/app/src/history.ts +50 -0
- package/app/src/queries.ts +163 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.forms.test.tsx +157 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.history.test.tsx +385 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.historycollab.test.tsx +433 -0
- package/app/vendor/design-system/src/__tests__/FormsBrowser.test.tsx +253 -0
- package/app/vendor/design-system/src/__tests__/ProtectedBranchModal.test.tsx +68 -0
- package/app/vendor/design-system/src/components/ChangeDetail.tsx +94 -11
- package/app/vendor/design-system/src/components/CollabField.tsx +71 -0
- package/app/vendor/design-system/src/components/ContentBrowser.tsx +366 -61
- package/app/vendor/design-system/src/components/DocumentHistory.tsx +408 -0
- package/app/vendor/design-system/src/components/FormsBrowser.tsx +543 -0
- package/app/vendor/design-system/src/components/ProtectedBranchModal.tsx +136 -0
- package/app/vendor/design-system/src/components/primitives.tsx +46 -1
- package/app/vendor/design-system/src/forms.ts +118 -0
- package/app/vendor/design-system/src/history.ts +82 -0
- package/app/vendor/design-system/src/index.ts +18 -1
- package/app/vendor/markdown-editor/src/MarkdownEditor.tsx +23 -0
- package/app/vendor/markdown-editor/src/field.tsx +6 -1
- package/app/vendor/markdown-editor/src/types.ts +13 -0
- package/npm-shrinkwrap.json +149 -188
- package/package.json +6 -6
|
@@ -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,253 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
3
|
+
import { ThemeProvider } from "../ThemeProvider";
|
|
4
|
+
import { FormsBrowser } from "../components/FormsBrowser";
|
|
5
|
+
import type { FormInfo, FormsApi, SubmissionInfo } from "../forms";
|
|
6
|
+
import { FORMS_NAV_KEY, formsNavKey, isFormsNavKey, parseFormsNavKey, summaryLine } from "../forms";
|
|
7
|
+
|
|
8
|
+
const contact: FormInfo = {
|
|
9
|
+
name: "contact",
|
|
10
|
+
label: "Contact us",
|
|
11
|
+
description: "General enquiries.",
|
|
12
|
+
fields: [
|
|
13
|
+
{ name: "email", label: "Email", type: "string" },
|
|
14
|
+
{ name: "reason", label: "Reason", type: "string" },
|
|
15
|
+
{ name: "orderNumber", label: "Order number", type: "string" },
|
|
16
|
+
],
|
|
17
|
+
fieldCount: 3,
|
|
18
|
+
submissionCount: 2,
|
|
19
|
+
versioned: true,
|
|
20
|
+
storePath: "content/submissions/contact/*.json",
|
|
21
|
+
canDelete: true,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const newsletter: FormInfo = {
|
|
25
|
+
name: "newsletter",
|
|
26
|
+
label: "Newsletter",
|
|
27
|
+
fields: [{ name: "email", type: "string" }],
|
|
28
|
+
fieldCount: 1,
|
|
29
|
+
submissionCount: 308,
|
|
30
|
+
versioned: false,
|
|
31
|
+
canDelete: false,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const submissions: SubmissionInfo[] = [
|
|
35
|
+
{
|
|
36
|
+
id: "s1",
|
|
37
|
+
form: "contact",
|
|
38
|
+
fields: { email: "priya@example.test", reason: "support", orderNumber: "A-1" },
|
|
39
|
+
submittedAt: "2026-08-28T12:00:00Z",
|
|
40
|
+
status: "received",
|
|
41
|
+
path: "content/submissions/contact/s1.json",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
// reason is "sales", so orderNumber was never asked for — it is ABSENT
|
|
45
|
+
// rather than null, which the detail view must show as such.
|
|
46
|
+
id: "s2",
|
|
47
|
+
form: "contact",
|
|
48
|
+
fields: { email: "sam@example.test", reason: "sales" },
|
|
49
|
+
submittedAt: "2026-08-27T09:30:00Z",
|
|
50
|
+
status: "received",
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
function makeApi(over: Partial<FormsApi> = {}): FormsApi {
|
|
55
|
+
return {
|
|
56
|
+
forms: [contact, newsletter],
|
|
57
|
+
list: jest.fn(async ({ status }) =>
|
|
58
|
+
status === "spam" ? [] : submissions,
|
|
59
|
+
),
|
|
60
|
+
count: jest.fn(async ({ status }) => (status === "spam" ? 0 : submissions.length)),
|
|
61
|
+
...over,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renderBrowser(props: Partial<React.ComponentProps<typeof FormsBrowser>> = {}) {
|
|
66
|
+
const api = props.api ?? makeApi();
|
|
67
|
+
const onSelectForm = props.onSelectForm ?? jest.fn();
|
|
68
|
+
const utils = render(
|
|
69
|
+
<ThemeProvider>
|
|
70
|
+
<FormsBrowser
|
|
71
|
+
api={api}
|
|
72
|
+
form={props.form ?? null}
|
|
73
|
+
onSelectForm={onSelectForm}
|
|
74
|
+
selectedId={props.selectedId}
|
|
75
|
+
onSelectSubmission={props.onSelectSubmission}
|
|
76
|
+
variant={props.variant ?? "desktop"}
|
|
77
|
+
/>
|
|
78
|
+
</ThemeProvider>,
|
|
79
|
+
);
|
|
80
|
+
return { ...utils, api, onSelectForm };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── nav keys ────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
describe("the forms nav-key space", () => {
|
|
86
|
+
it("cannot collide with a collection, because a collection name has no colon", () => {
|
|
87
|
+
expect(isFormsNavKey("forms")).toBe(false); // a collection literally named "forms"
|
|
88
|
+
expect(isFormsNavKey(FORMS_NAV_KEY)).toBe(true);
|
|
89
|
+
expect(isFormsNavKey(formsNavKey("contact"))).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("tells the Forms button apart from a chosen form", () => {
|
|
93
|
+
expect(parseFormsNavKey(FORMS_NAV_KEY)).toBeNull();
|
|
94
|
+
expect(parseFormsNavKey(formsNavKey("contact"))).toBe("contact");
|
|
95
|
+
expect(parseFormsNavKey("posts")).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ── the forms list ──────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
describe("the forms list", () => {
|
|
102
|
+
it("shows each form's name, description and submission count", () => {
|
|
103
|
+
renderBrowser();
|
|
104
|
+
expect(screen.getByText("Contact us")).toBeTruthy();
|
|
105
|
+
expect(screen.getByText("General enquiries.")).toBeTruthy();
|
|
106
|
+
expect(screen.getByText("2")).toBeTruthy();
|
|
107
|
+
expect(screen.getByText("308")).toBeTruthy();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("does not carry the field count, which describes the form and not its inbox", () => {
|
|
111
|
+
renderBrowser();
|
|
112
|
+
expect(screen.queryByText("3 fields")).toBeNull();
|
|
113
|
+
expect(screen.queryByText(/\bfields?\b/)).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("opens a form when its row is pressed", () => {
|
|
117
|
+
const { onSelectForm } = renderBrowser();
|
|
118
|
+
fireEvent.click(screen.getByTestId("form-row-contact"));
|
|
119
|
+
expect(onSelectForm).toHaveBeenCalledWith("contact");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("says so when the branch declares no forms", () => {
|
|
123
|
+
renderBrowser({ api: makeApi({ forms: [] }) });
|
|
124
|
+
expect(screen.getByText(/declares no forms/i)).toBeTruthy();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ── submissions ─────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
describe("a form's submissions", () => {
|
|
131
|
+
it("lists them with a summary line built from what the person wrote", async () => {
|
|
132
|
+
renderBrowser({ form: "contact" });
|
|
133
|
+
await waitFor(() => expect(screen.getByTestId("submission-row-s1")).toBeTruthy());
|
|
134
|
+
// The email is preferred: it is what a reader scans an inbox for.
|
|
135
|
+
expect(screen.getByText("priya@example.test")).toBeTruthy();
|
|
136
|
+
expect(screen.getByText("sam@example.test")).toBeTruthy();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("reports a tap so the host can bind it to the URL", async () => {
|
|
140
|
+
const onSelectSubmission = jest.fn();
|
|
141
|
+
renderBrowser({ form: "contact", onSelectSubmission });
|
|
142
|
+
await waitFor(() => expect(screen.getByTestId("submission-row-s1")).toBeTruthy());
|
|
143
|
+
fireEvent.click(screen.getByTestId("submission-row-s1"));
|
|
144
|
+
expect(onSelectSubmission).toHaveBeenCalledWith("s1");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("goes back to the forms list", async () => {
|
|
148
|
+
const { onSelectForm } = renderBrowser({ form: "contact" });
|
|
149
|
+
await waitFor(() => expect(screen.getByTestId("forms-back")).toBeTruthy());
|
|
150
|
+
fireEvent.click(screen.getByTestId("forms-back"));
|
|
151
|
+
expect(onSelectForm).toHaveBeenCalledWith(null);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("has somewhere to see spam, since a honeypot hit is recorded not discarded", async () => {
|
|
155
|
+
const { api } = renderBrowser({ form: "contact" });
|
|
156
|
+
await waitFor(() => expect(screen.getByTestId("forms-status-toggle")).toBeTruthy());
|
|
157
|
+
fireEvent.click(screen.getByTestId("forms-status-toggle"));
|
|
158
|
+
await waitFor(() =>
|
|
159
|
+
expect(api.list).toHaveBeenCalledWith(expect.objectContaining({ status: "spam" })),
|
|
160
|
+
);
|
|
161
|
+
await waitFor(() => expect(screen.getByText(/Nothing caught as spam/i)).toBeTruthy());
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ── the read-only submission ────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
describe("a selected submission", () => {
|
|
168
|
+
it("renders every value read-only, with no editable control", async () => {
|
|
169
|
+
renderBrowser({ form: "contact", selectedId: "s1" });
|
|
170
|
+
await waitFor(() => expect(screen.getByText("A-1")).toBeTruthy());
|
|
171
|
+
// The email shows twice — once as the row's summary line, once as the
|
|
172
|
+
// detail's value — which is what a list beside a detail looks like.
|
|
173
|
+
expect(screen.getAllByText("priya@example.test").length).toBeGreaterThan(0);
|
|
174
|
+
expect(screen.getByText("support")).toBeTruthy();
|
|
175
|
+
// A submission is a record of what someone sent, not a document with
|
|
176
|
+
// editing switched off.
|
|
177
|
+
expect(screen.queryByDisplayValue("priya@example.test")).toBeNull();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("distinguishes a field that was never asked for from one left blank", async () => {
|
|
181
|
+
renderBrowser({ form: "contact", selectedId: "s2" });
|
|
182
|
+
await waitFor(() => expect(screen.getByTestId("submission-not-asked-orderNumber")).toBeTruthy());
|
|
183
|
+
expect(screen.getByText("Not asked")).toBeTruthy();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("shows where a versioned submission lives in the repository", async () => {
|
|
187
|
+
renderBrowser({ form: "contact", selectedId: "s1" });
|
|
188
|
+
await waitFor(() =>
|
|
189
|
+
expect(screen.getByText("content/submissions/contact/s1.json")).toBeTruthy(),
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("omits the repository path for a submission git does not hold", async () => {
|
|
194
|
+
renderBrowser({ form: "contact", selectedId: "s2" });
|
|
195
|
+
await waitFor(() => expect(screen.getByText("Not asked")).toBeTruthy());
|
|
196
|
+
expect(screen.queryByText(/In the repository/i)).toBeNull();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ── the summary line ────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
describe("summaryLine", () => {
|
|
203
|
+
it("prefers an email-ish value, then the first readable text", () => {
|
|
204
|
+
expect(summaryLine(submissions[0], contact)).toBe("priya@example.test");
|
|
205
|
+
expect(
|
|
206
|
+
summaryLine(
|
|
207
|
+
{ ...submissions[0], fields: { reason: "support", orderNumber: "A-1" } },
|
|
208
|
+
contact,
|
|
209
|
+
),
|
|
210
|
+
).toBe("support");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("says so rather than rendering an empty row", () => {
|
|
214
|
+
expect(summaryLine({ ...submissions[0], fields: { agreed: true } }, contact)).toBe("(no text)");
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// A form can legitimately fail to list — local mode answers NO_SUBMISSION_STORE
|
|
219
|
+
// for a form that keeps its submissions in a database it does not have. Before
|
|
220
|
+
// this had somewhere to go, the promise rejected and the pane spun forever.
|
|
221
|
+
describe("a form whose submissions cannot be listed", () => {
|
|
222
|
+
const failing = (): FormsApi => ({
|
|
223
|
+
forms: [contact],
|
|
224
|
+
list: jest.fn().mockRejectedValue(
|
|
225
|
+
Object.assign(new Error("network"), {
|
|
226
|
+
graphQLErrors: [{ message: "form \"newsletter\" keeps its submissions in a database" }],
|
|
227
|
+
}),
|
|
228
|
+
),
|
|
229
|
+
count: jest.fn().mockRejectedValue(new Error("network")),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("stops loading instead of spinning forever", async () => {
|
|
233
|
+
renderBrowser({ api: failing(), form: "contact" });
|
|
234
|
+
await waitFor(() => expect(screen.getByTestId("forms-error")).toBeTruthy());
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("shows the server's own sentence, which says what to change", async () => {
|
|
238
|
+
renderBrowser({ api: failing(), form: "contact" });
|
|
239
|
+
await waitFor(() =>
|
|
240
|
+
expect(screen.getByText(/keeps its submissions in a database/)).toBeTruthy(),
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("falls back to something readable when the error carries no message", async () => {
|
|
245
|
+
const api: FormsApi = {
|
|
246
|
+
forms: [contact],
|
|
247
|
+
list: jest.fn().mockRejectedValue({}),
|
|
248
|
+
count: jest.fn().mockRejectedValue({}),
|
|
249
|
+
};
|
|
250
|
+
renderBrowser({ api, form: "contact" });
|
|
251
|
+
await waitFor(() => expect(screen.getByText(/could not be loaded/)).toBeTruthy());
|
|
252
|
+
});
|
|
253
|
+
});
|