@gogitcms/editor 0.26.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.
Files changed (24) hide show
  1. package/app/src/App.tsx +163 -1
  2. package/app/src/forms.ts +77 -0
  3. package/app/src/history.ts +50 -0
  4. package/app/src/queries.ts +163 -0
  5. package/app/vendor/design-system/src/__tests__/ContentBrowser.forms.test.tsx +157 -0
  6. package/app/vendor/design-system/src/__tests__/ContentBrowser.history.test.tsx +385 -0
  7. package/app/vendor/design-system/src/__tests__/ContentBrowser.historycollab.test.tsx +433 -0
  8. package/app/vendor/design-system/src/__tests__/FormsBrowser.test.tsx +253 -0
  9. package/app/vendor/design-system/src/__tests__/ProtectedBranchModal.test.tsx +68 -0
  10. package/app/vendor/design-system/src/components/ChangeDetail.tsx +94 -11
  11. package/app/vendor/design-system/src/components/CollabField.tsx +71 -0
  12. package/app/vendor/design-system/src/components/ContentBrowser.tsx +366 -61
  13. package/app/vendor/design-system/src/components/DocumentHistory.tsx +408 -0
  14. package/app/vendor/design-system/src/components/FormsBrowser.tsx +543 -0
  15. package/app/vendor/design-system/src/components/ProtectedBranchModal.tsx +136 -0
  16. package/app/vendor/design-system/src/components/primitives.tsx +46 -1
  17. package/app/vendor/design-system/src/forms.ts +118 -0
  18. package/app/vendor/design-system/src/history.ts +82 -0
  19. package/app/vendor/design-system/src/index.ts +18 -1
  20. package/app/vendor/markdown-editor/src/MarkdownEditor.tsx +23 -0
  21. package/app/vendor/markdown-editor/src/field.tsx +6 -1
  22. package/app/vendor/markdown-editor/src/types.ts +13 -0
  23. package/npm-shrinkwrap.json +149 -188
  24. 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
- status: "A" | "M" | "D" | "R";
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({ field }: { field: FieldChange }) {
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
- <Text variant="label" color="tertiary">
151
- {(field.label || field.name).toUpperCase()}
152
- </Text>
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({ before, after }: { before?: string | null; after?: string | null }) {
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
- <Text variant="label" color="tertiary">BODY</Text>
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({ change, loading, emptyMessage = "Select a change", conflicts, onResolveConflict }: ChangeDetailProps) {
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 key={f.name} field={f} />
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 before={change.bodyBefore} after={change.bodyAfter} />
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
+ }