@gogitcms/editor 0.26.0 → 0.29.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 +155 -168
- package/package.json +6 -6
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
3
|
+
import { ThemeProvider } from "../ThemeProvider";
|
|
4
|
+
import { ProtectedBranchModal } from "../components/ProtectedBranchModal";
|
|
5
|
+
|
|
6
|
+
const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
|
|
7
|
+
|
|
8
|
+
const base = { branch: "main", onCreate: () => {}, onCancel: () => {} };
|
|
9
|
+
|
|
10
|
+
test("it names the branch, the document, and offers the one remedy", () => {
|
|
11
|
+
render(wrap(<ProtectedBranchModal {...base} documentLabel="Hello world" />));
|
|
12
|
+
|
|
13
|
+
const modal = screen.getByTestId("protected-branch-modal");
|
|
14
|
+
expect(modal).toHaveTextContent("This branch is protected");
|
|
15
|
+
expect(modal).toHaveTextContent("main");
|
|
16
|
+
expect(modal).toHaveTextContent("Hello world");
|
|
17
|
+
expect(modal).toHaveTextContent("Branched from main");
|
|
18
|
+
expect(screen.getByTestId("protected-branch-submit")).toHaveTextContent("Create branch and save");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("the suggested name prefills the field and submits", () => {
|
|
22
|
+
const onCreate = jest.fn();
|
|
23
|
+
render(wrap(<ProtectedBranchModal {...base} suggestedName="edit/hello" onCreate={onCreate} />));
|
|
24
|
+
|
|
25
|
+
expect(screen.getByTestId("protected-branch-name")).toHaveValue("edit/hello");
|
|
26
|
+
fireEvent.click(screen.getByTestId("protected-branch-submit"));
|
|
27
|
+
expect(onCreate).toHaveBeenCalledWith("edit/hello");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// The author's own name wins: a suggestion arriving late (the host resolves the
|
|
31
|
+
// document a render after the modal opens) must not overwrite what they typed.
|
|
32
|
+
test("a late suggestion does not overwrite a typed name", () => {
|
|
33
|
+
const { rerender } = render(wrap(<ProtectedBranchModal {...base} />));
|
|
34
|
+
fireEvent.change(screen.getByTestId("protected-branch-name"), { target: { value: "mine" } });
|
|
35
|
+
rerender(wrap(<ProtectedBranchModal {...base} suggestedName="edit/hello" />));
|
|
36
|
+
expect(screen.getByTestId("protected-branch-name")).toHaveValue("mine");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("an empty name cannot be submitted", () => {
|
|
40
|
+
const onCreate = jest.fn();
|
|
41
|
+
render(wrap(<ProtectedBranchModal {...base} onCreate={onCreate} />));
|
|
42
|
+
fireEvent.click(screen.getByTestId("protected-branch-submit"));
|
|
43
|
+
expect(onCreate).not.toHaveBeenCalled();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// While the branch is being created there is no way out but forward: cancelling
|
|
47
|
+
// mid-flight would abandon a save whose branch may already exist.
|
|
48
|
+
test("busy disables both actions and hides the close control", () => {
|
|
49
|
+
const onCreate = jest.fn();
|
|
50
|
+
render(wrap(<ProtectedBranchModal {...base} suggestedName="edit/hello" busy onCreate={onCreate} />));
|
|
51
|
+
|
|
52
|
+
expect(screen.getByTestId("protected-branch-submit")).toHaveTextContent("Creating…");
|
|
53
|
+
expect(screen.queryByTestId("protected-branch-close")).not.toBeInTheDocument();
|
|
54
|
+
fireEvent.click(screen.getByTestId("protected-branch-submit"));
|
|
55
|
+
expect(onCreate).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("the server's message is shown so a rejected name can be corrected", () => {
|
|
59
|
+
render(wrap(<ProtectedBranchModal {...base} error={`branch "edit/hello" already exists`} />));
|
|
60
|
+
expect(screen.getByTestId("protected-branch-error")).toHaveTextContent("already exists");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("cancel dismisses", () => {
|
|
64
|
+
const onCancel = jest.fn();
|
|
65
|
+
render(wrap(<ProtectedBranchModal {...base} onCancel={onCancel} />));
|
|
66
|
+
fireEvent.click(screen.getByTestId("protected-branch-cancel"));
|
|
67
|
+
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
68
|
+
});
|
|
@@ -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";
|
|
@@ -38,7 +38,15 @@ export type DocumentChange = {
|
|
|
38
38
|
path: string;
|
|
39
39
|
/** The base branch's path, for a rename. */
|
|
40
40
|
previousPath?: string;
|
|
41
|
-
|
|
41
|
+
/**
|
|
42
|
+
* A/M/D/R are git's letters, so a branch comparison renders them untranslated.
|
|
43
|
+
* "U" (unchanged) has no place in a diff of two branches — an unchanged
|
|
44
|
+
* document is not a change — but it is a real answer for a document version:
|
|
45
|
+
* the newest commit in a document's history is usually still what the document
|
|
46
|
+
* is, and saying "Unchanged" is more useful than an empty diff with no
|
|
47
|
+
* explanation.
|
|
48
|
+
*/
|
|
49
|
+
status: "A" | "M" | "D" | "R" | "U";
|
|
42
50
|
label: string;
|
|
43
51
|
/** Ordered to match the content model's schema, so it reads like the editor. */
|
|
44
52
|
fields: FieldChange[];
|
|
@@ -79,13 +87,35 @@ export type ChangeDetailProps = {
|
|
|
79
87
|
*/
|
|
80
88
|
conflicts?: FieldConflict[];
|
|
81
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;
|
|
82
104
|
};
|
|
83
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
|
+
|
|
84
113
|
const STATUS_LABEL: Record<DocumentChange["status"], string> = {
|
|
85
114
|
A: "Added",
|
|
86
115
|
M: "Modified",
|
|
87
116
|
D: "Deleted",
|
|
88
117
|
R: "Renamed",
|
|
118
|
+
U: "Unchanged",
|
|
89
119
|
};
|
|
90
120
|
|
|
91
121
|
/** Renders a field value the way the editor would show it, not as raw JSON. */
|
|
@@ -142,14 +172,29 @@ function ContextValue({ value }: { value: unknown }) {
|
|
|
142
172
|
);
|
|
143
173
|
}
|
|
144
174
|
|
|
145
|
-
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
|
+
}) {
|
|
146
186
|
const t = useTheme();
|
|
147
187
|
const changed = field.kind !== "unchanged";
|
|
148
188
|
return (
|
|
149
189
|
<View testID={`change-field-${field.name}`} style={{ gap: t.space(2) }}>
|
|
150
|
-
<
|
|
151
|
-
{
|
|
152
|
-
|
|
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>
|
|
153
198
|
{changed ? (
|
|
154
199
|
<View
|
|
155
200
|
style={{
|
|
@@ -285,7 +330,17 @@ function ConflictSide({ label, value, chosen }: { label: string; value: unknown;
|
|
|
285
330
|
* stacked blocks rather than a single line — the whole before and the whole
|
|
286
331
|
* after, each tinted, so a reviewer can read them.
|
|
287
332
|
*/
|
|
288
|
-
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
|
+
}) {
|
|
289
344
|
const t = useTheme();
|
|
290
345
|
if ((before ?? "") === (after ?? "")) {
|
|
291
346
|
if (!after) return null;
|
|
@@ -298,7 +353,12 @@ function BodyDiff({ before, after }: { before?: string | null; after?: string |
|
|
|
298
353
|
}
|
|
299
354
|
return (
|
|
300
355
|
<View testID="change-body" style={{ gap: t.space(2) }}>
|
|
301
|
-
<
|
|
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>
|
|
302
362
|
<View
|
|
303
363
|
style={{
|
|
304
364
|
borderRadius: t.radius.md,
|
|
@@ -374,8 +434,21 @@ function AssetPreview({ preview }: { preview: ChangePreview }) {
|
|
|
374
434
|
* context. Strictly read-only — editing happens on the Edit surface, and this
|
|
375
435
|
* view describes a comparison rather than a document you can save.
|
|
376
436
|
*/
|
|
377
|
-
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) {
|
|
378
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;
|
|
379
452
|
|
|
380
453
|
// Conflicts keyed by field name (and a body flag), so each field can check for
|
|
381
454
|
// one as it renders. The body conflict is looked up separately.
|
|
@@ -445,7 +518,12 @@ export function ChangeDetail({ change, loading, emptyMessage = "Select a change"
|
|
|
445
518
|
onResolve={onResolveConflict ? (choice, override) => onResolveConflict(conflict, choice, override) : undefined}
|
|
446
519
|
/>
|
|
447
520
|
) : (
|
|
448
|
-
<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
|
+
/>
|
|
449
527
|
);
|
|
450
528
|
})}
|
|
451
529
|
{/* Conflicts on fields the schema-ordered diff didn't surface (rare). */}
|
|
@@ -464,7 +542,12 @@ export function ChangeDetail({ change, loading, emptyMessage = "Select a change"
|
|
|
464
542
|
onResolve={onResolveConflict ? (choice, override) => onResolveConflict(bodyConflict!, choice, override) : undefined}
|
|
465
543
|
/>
|
|
466
544
|
) : (
|
|
467
|
-
<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
|
+
/>
|
|
468
551
|
)}
|
|
469
552
|
</ScrollView>
|
|
470
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
|
+
}
|