@gogitcms/design-system 0.16.0-next.3 → 0.16.0-next.5

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.
@@ -6,7 +6,7 @@ import { Text } from "./Text";
6
6
  import { Icon } from "./Icon";
7
7
  import { Button, IconButton } from "./Button";
8
8
  import { Input } from "./Input";
9
- import { Avatar, Badge, DiffStat, Divider, SectionLabel } from "./primitives";
9
+ import { Avatar, Badge, CheckBox, DiffStat, Divider, SectionLabel } from "./primitives";
10
10
  import { NavRow } from "./NavRow";
11
11
  import { AppShell, MobileScreen, Pane, TopBar, ThemeToggle } from "./layout";
12
12
  import { Segment, type SegmentItem } from "./Segment";
@@ -23,6 +23,7 @@ import {
23
23
  PresenceField,
24
24
  PresenceAvatars,
25
25
  useCollabRegister,
26
+ writeCollabValue,
26
27
  useCollabMirror,
27
28
  useCollabSynced,
28
29
  useCollabValue,
@@ -34,6 +35,8 @@ import { MediaField, MediaProvider, DocumentPathProvider } from "./MediaField";
34
35
  import { MediaBrowser } from "./MediaBrowser";
35
36
  import { FormsBrowser } from "./FormsBrowser";
36
37
  import { FORMS_NAV_KEY, formsNavKey, isFormsNavKey, parseFormsNavKey, type FormsApi } from "../forms";
38
+ import { type HistoryApi } from "../history";
39
+ import { DocumentHistory } from "./DocumentHistory";
37
40
  import {
38
41
  MEDIA_NAV_KEY,
39
42
  mediaNavKey,
@@ -112,6 +115,12 @@ export type FieldSlotArgs = {
112
115
  // CRDT and publish presence.
113
116
  collab?: CollabApi;
114
117
  path?: string;
118
+ // Bumped when the host replaces this field's value outright rather than the
119
+ // user editing it — restoring it from a past version. A controlled control
120
+ // needs nothing from this (it already re-renders with the new value), but the
121
+ // body editor does: in a live session it ignores `value` and mirrors the
122
+ // shared fragment, so "the value changed" is not a thing it can observe.
123
+ resetNonce?: number;
115
124
  };
116
125
  export type RenderField = (args: FieldSlotArgs) => React.ReactNode;
117
126
 
@@ -340,6 +349,14 @@ export type ContentBrowserProps = {
340
349
  // Forms data seam, the same split (docs/forms.md §10.2). Omitted → the Forms
341
350
  // surface never renders, which is what a config declaring no forms looks like.
342
351
  forms?: FormsApi;
352
+
353
+ // Document-history data seam, the same split again: the DS owns the version
354
+ // browser and its ephemeral state, the host owns fetching. Provided → an open
355
+ // document's header gains a history button, and pressing it gives that
356
+ // column's body over to the version browser. Omitted → no affordance at all,
357
+ // which is what a deployment with no provider (local mode, desktop) gets: a
358
+ // feature the user never sees beats one that errors when clicked.
359
+ history?: HistoryApi;
343
360
  // The selected submission's id, and the reporter for taps — bound to the URL
344
361
  // by the host exactly as document selection is.
345
362
  selectedSubmissionId?: string | null;
@@ -757,6 +774,11 @@ type FieldControlProps = {
757
774
  // When this number changes, the field programmatically focuses itself (used to
758
775
  // jump to a peer's field from its presence avatar).
759
776
  focusSignal?: number;
777
+ // Bumped when this field's value was replaced by the host rather than typed —
778
+ // restored from a past version. Only the host-supplied body editor needs it
779
+ // (see FieldSlotArgs.resetNonce); every built-in control is controlled and
780
+ // re-renders from `value` on its own.
781
+ resetNonce?: number;
760
782
  };
761
783
 
762
784
  // The scalar type an array's `of` maps to when rendering item controls.
@@ -770,7 +792,7 @@ function ofToType(of?: string): string {
770
792
  }
771
793
 
772
794
  function FieldControl(props: FieldControlProps) {
773
- const { field, value, onChange, renderField, readOnly = false, error, hideLabel, onOpenGroup, collab, path, focusSignal } = props;
795
+ const { field, value, onChange, renderField, readOnly = false, error, hideLabel, onOpenGroup, collab, path, focusSignal, resetNonce } = props;
774
796
  const t = useTheme();
775
797
  const label = hideLabel ? "" : field.label || field.name;
776
798
  // A text field participates in live collaboration when the document has a
@@ -783,7 +805,7 @@ function FieldControl(props: FieldControlProps) {
783
805
 
784
806
  // Host-supplied control (e.g. the markdown editor) takes precedence.
785
807
  if (renderField) {
786
- const custom = renderField({ field, value, onChange, readOnly, collab: collabText, path });
808
+ const custom = renderField({ field, value, onChange, readOnly, collab: collabText, path, resetNonce });
787
809
  if (custom != null && custom !== false) {
788
810
  return (
789
811
  <View style={{ gap: t.space(2) }}>
@@ -2581,6 +2603,7 @@ function EntryDetail({
2581
2603
  onMove,
2582
2604
  collectionPath,
2583
2605
  folderOptions,
2606
+ history,
2584
2607
  active,
2585
2608
  onActivate,
2586
2609
  onCollapse,
@@ -2603,6 +2626,10 @@ function EntryDetail({
2603
2626
  collectionPath?: string;
2604
2627
  // Existing folders (relative to the glob base) offered by the move autocomplete.
2605
2628
  folderOptions?: string[];
2629
+ // The document-history seam. Provided → a history button appears in this
2630
+ // document's header and its column can show the version browser. Omitted →
2631
+ // the header renders exactly as it did before history existed.
2632
+ history?: HistoryApi;
2606
2633
  // Desktop columns: `active` marks the focused column (accent + header press
2607
2634
  // activates it via onActivate); onCollapse/onClose add the header rail/close
2608
2635
  // controls. All omitted on mobile → unchanged single-detail behavior.
@@ -2623,6 +2650,23 @@ function EntryDetail({
2623
2650
  const canRename = !readOnly && !!onRename;
2624
2651
  const canMove = !readOnly && !!onMove && glob.hierarchical;
2625
2652
  const [prompt, setPrompt] = useState<"rename" | "move" | null>(null);
2653
+ // Per-field "your value was replaced" counters, bumped by a restore. Only the
2654
+ // host-supplied body editor reads them (see FieldSlotArgs.resetNonce); every
2655
+ // built-in control is controlled and needs no telling.
2656
+ const [resetNonces, setResetNonces] = useState<Record<string, number>>({});
2657
+
2658
+ // Whether this column is showing the version browser instead of the form.
2659
+ // Deliberately per-column and ephemeral: history is something you open, read
2660
+ // and close, so it lives and dies with the column rather than in the URL.
2661
+ const [showHistory, setShowHistory] = useState(false);
2662
+ // A column reused for another document (the "preview tab" behavior) must not
2663
+ // carry the previous document's history view onto it — you asked to see that
2664
+ // document, not this one's past.
2665
+ const historyFor = useRef(entry.id);
2666
+ if (historyFor.current !== entry.id) {
2667
+ historyFor.current = entry.id;
2668
+ if (showHistory) setShowHistory(false);
2669
+ }
2626
2670
 
2627
2671
  // Both branches share one value map. The title+body branch is modeled as a
2628
2672
  // single body-source field so autosave logic is uniform.
@@ -2805,6 +2849,112 @@ function EntryDetail({
2805
2849
  [commit],
2806
2850
  );
2807
2851
 
2852
+ // Publish a value map the form did NOT arrive at by typing — a restore from a
2853
+ // past version, or a discard back to the saved document.
2854
+ //
2855
+ // Both used to write local state and stop there, which is only half the job in
2856
+ // a live session. The shared document is the source of truth there: every
2857
+ // collab control prefers its binding to the value handed to it, so a replaced
2858
+ // value was displayed for one frame and then overwritten by the room's
2859
+ // unchanged one, and no peer ever saw it. Anything that replaces values
2860
+ // wholesale has to come through here.
2861
+ //
2862
+ // `names` are the fields whose values were replaced; unchanged ones are left
2863
+ // alone so a replacement never churns the room (or nudges a peer's cursor)
2864
+ // over a value that did not move.
2865
+ const publishReplacement = useCallback(
2866
+ (previous: Record<string, unknown>, next: Record<string, unknown>, names: string[]) => {
2867
+ const moved = names.filter((name) => !sameValue(previous[name], next[name]));
2868
+ if (moved.length === 0) return;
2869
+
2870
+ if (entry.collab) {
2871
+ for (const name of moved) {
2872
+ // A body is a ProseMirror fragment rather than a field binding, so it
2873
+ // is not written here — its editor is told instead, by the nonce below.
2874
+ if (editFields.find((f) => f.name === name)?.source === "body") continue;
2875
+ writeCollabValue(entry.collab, name, previous[name], next[name]);
2876
+ }
2877
+ }
2878
+
2879
+ // Tell the replaced fields their value came from outside. Keyed per field
2880
+ // rather than one counter for the document: the body editor acts on this,
2881
+ // and replacing some unrelated field must not make it rebuild a body a
2882
+ // peer may be mid-sentence in.
2883
+ setResetNonces((prev) => {
2884
+ const bumped = { ...prev };
2885
+ for (const name of moved) bumped[name] = (bumped[name] ?? 0) + 1;
2886
+ return bumped;
2887
+ });
2888
+ },
2889
+ // editFields is derived from entry, captured by id.
2890
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2891
+ [entry.id, entry.collab],
2892
+ );
2893
+
2894
+ // Values taken from a past version and put back into the form.
2895
+ //
2896
+ // It is an edit like any other: the restored fields land in `values`, the
2897
+ // document goes unsaved, and the author saves it (or discards it) themselves.
2898
+ // Nothing is written to git here — history is a place you read, and rewriting
2899
+ // the document from it should still pass through the same review a typed edit
2900
+ // does.
2901
+ const restoreValues = useCallback(
2902
+ (restored: { fields: Record<string, unknown>; body?: string | null }) => {
2903
+ const previous = valuesRef.current;
2904
+ const next = { ...previous, ...restored.fields };
2905
+ // The body is a field in the form like any other; which one it is comes
2906
+ // from the schema (`source: "body"`), the same mapping buildChange uses in
2907
+ // the other direction. A document whose model has no body field simply
2908
+ // has no body to restore.
2909
+ const bodyField = "body" in restored ? editFields.find((f) => f.source === "body") : undefined;
2910
+ if (bodyField) next[bodyField.name] = restored.body ?? "";
2911
+
2912
+ const replaced = Object.keys(restored.fields);
2913
+ if (bodyField) replaced.push(bodyField.name);
2914
+
2915
+ // Touched under the first restored name so a restore that leaves a
2916
+ // required field empty shows its error immediately, rather than looking
2917
+ // saveable until Save is pressed.
2918
+ //
2919
+ // Committed BEFORE publishing: a collab control observing the room echoes
2920
+ // what it sees back through onChange, and that echo builds its next map by
2921
+ // spreading the current one. Publishing first would have it spread the
2922
+ // pre-restore map.
2923
+ commit(next, replaced[0] ?? SYNTHETIC_BODY);
2924
+ publishReplacement(previous, next, replaced);
2925
+ // Drilled-into groups address the pre-restore shape; a restored group
2926
+ // object can have different children entirely.
2927
+ setGroupPath([]);
2928
+ },
2929
+ // editFields is derived from entry, which commit already captures by id.
2930
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2931
+ [commit, entry.id, publishReplacement],
2932
+ );
2933
+
2934
+ // Throw away everything unsaved and go back to the document as stored.
2935
+ //
2936
+ // In a live session that is a change to the ROOM, not just to this screen: the
2937
+ // unsaved work is the collective state every client is looking at, and a
2938
+ // discard that only reverted the local form would leave the document it just
2939
+ // claimed to restore sitting in the CRDT, ready to come straight back.
2940
+ const discardEdits = useCallback(() => {
2941
+ const previous = valuesRef.current;
2942
+ const restored = seed();
2943
+ valuesRef.current = restored;
2944
+ setValues(restored);
2945
+ setTouched({});
2946
+ // The preview is rendering the draft being thrown away, and nothing else
2947
+ // will tell it otherwise: it is fed from edits, and a discard is the one
2948
+ // change to a document that produces no edit. Without this the preview
2949
+ // keeps showing work the author just deleted, until they type again.
2950
+ emitDraft(buildChange(entry.id, editFields, restored), entry);
2951
+ discard();
2952
+ publishReplacement(previous, restored, editFields.map((f) => f.name));
2953
+ setGroupPath([]);
2954
+ // seed/editFields derive from entry, captured by id.
2955
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2956
+ }, [entry.id, discard, emitDraft, publishReplacement]);
2957
+
2808
2958
  // A field edit within the active group-drill frame: write the value at the
2809
2959
  // frame's nested path, then commit the whole map (buildChange serializes it).
2810
2960
  const changeInFrame = useCallback(
@@ -2868,19 +3018,7 @@ function EntryDetail({
2868
3018
  {saveHandler && dirty ? (
2869
3019
  <Pressable
2870
3020
  testID="discard-changes"
2871
- onPress={() => {
2872
- const restored = seed();
2873
- valuesRef.current = restored;
2874
- setValues(restored);
2875
- setTouched({});
2876
- // The preview is rendering the draft that is being thrown away,
2877
- // and nothing else will tell it otherwise: it is fed from edits,
2878
- // and a discard is the one change to a document that produces no
2879
- // edit. Without this the preview keeps showing work the author
2880
- // just deleted, until they type again.
2881
- emitDraft(buildChange(entry.id, editFields, restored), entry);
2882
- discard();
2883
- }}
3021
+ onPress={discardEdits}
2884
3022
  style={{ paddingHorizontal: t.space(2), paddingVertical: t.space(1) }}
2885
3023
  >
2886
3024
  <Text variant="monoSm" color="tertiary">Discard</Text>
@@ -2908,6 +3046,20 @@ function EntryDetail({
2908
3046
  document's own actions, while the menu holds the destructive and
2909
3047
  path-changing ones that should stay one level down. */}
2910
3048
  {documentActions?.(entry)}
3049
+ {/* History is a read of this document, not a change to it, so it sits
3050
+ out here with the other non-destructive actions rather than in the
3051
+ menu beside Delete. It toggles: pressing it again returns the column
3052
+ to the form. */}
3053
+ {history ? (
3054
+ <IconButton
3055
+ name="history"
3056
+ size="sm"
3057
+ active={showHistory}
3058
+ label={showHistory ? "Close history" : "Document history"}
3059
+ onPress={() => setShowHistory((v) => !v)}
3060
+ testID="document-history-toggle"
3061
+ />
3062
+ ) : null}
2911
3063
  {canRename || canMove || (canDelete && onDeleteEntry) ? (
2912
3064
  <DetailMenu
2913
3065
  onRename={canRename ? () => setPrompt("rename") : undefined}
@@ -2923,10 +3075,35 @@ function EntryDetail({
2923
3075
  ) : null}
2924
3076
  </View>
2925
3077
 
2926
- {/* Keyed by entry.id so controls (incl. the markdown editor) remount with
3078
+ {/* History takes over the column's body, keeping its header: the header
3079
+ is what says which document you are looking at, and it carries the
3080
+ control that got you here and gets you back. */}
3081
+ {showHistory && history ? (
3082
+ <DocumentHistory
3083
+ documentId={entry.id}
3084
+ documentPath={entry.path}
3085
+ history={history}
3086
+ onClose={() => setShowHistory(false)}
3087
+ // Restoring needs somewhere for the values to go: a document that is
3088
+ // read-only, or that this host never wired a save for, gets the
3089
+ // history with no selection boxes at all rather than a form it can
3090
+ // dirty and never save. (saveHandler is already undefined when
3091
+ // readOnly, so this covers both.)
3092
+ onRestore={
3093
+ !saveHandler
3094
+ ? undefined
3095
+ : (restored) => {
3096
+ restoreValues(restored);
3097
+ // Back to the form, which is where the restored values now
3098
+ // are and where the decision to keep them is made.
3099
+ setShowHistory(false);
3100
+ }
3101
+ }
3102
+ />
3103
+ ) : /* Keyed by entry.id so controls (incl. the markdown editor) remount with
2927
3104
  fresh initial values (and the scroll resets to top) when the selected
2928
- document changes. The header above stays put; only the body scrolls. */}
2929
- {hasFields ? (
3105
+ document changes. The header above stays put; only the body scrolls. */
3106
+ hasFields ? (
2930
3107
  // The document's path reaches media fields at any nesting depth through
2931
3108
  // context: a picker inside a group or a list item needs it to interpret
2932
3109
  // relative references, and drilling it through every level would touch
@@ -2964,6 +3141,7 @@ function EntryDetail({
2964
3141
  collab={entry.collab}
2965
3142
  path={[...groupPath, f.name].join(".")}
2966
3143
  focusSignal={focusTarget && focusTarget.name === f.name ? focusTarget.nonce : undefined}
3144
+ resetNonce={resetNonces[f.name]}
2967
3145
  />
2968
3146
  ))}
2969
3147
  </ScrollView>
@@ -3043,27 +3221,6 @@ function useMultiSelect(resetKey: string) {
3043
3221
  return { checked, toggle, clear, checkedIds, checkedFolders, toggleFolder, checkedFolderKeys };
3044
3222
  }
3045
3223
 
3046
- // CheckBox is the row-selection control for multi-select.
3047
- function CheckBox({ value, onToggle }: { value: boolean; onToggle: () => void }) {
3048
- const t = useTheme();
3049
- return (
3050
- <Pressable
3051
- // Stop the press from bubbling to the row's open handler (the checkbox
3052
- // sits inside the row Pressable) so toggling never also opens the entry.
3053
- onPress={(e?: { stopPropagation?: () => void }) => { e?.stopPropagation?.(); onToggle(); }}
3054
- testID="entry-checkbox"
3055
- style={{
3056
- width: 18, height: 18, borderRadius: t.radius.sm, borderWidth: 1,
3057
- borderColor: value ? t.color.borderStrong : t.color.borderDefault,
3058
- backgroundColor: value ? t.color.surfaceInverted : t.color.surfaceRaised,
3059
- alignItems: "center", justifyContent: "center",
3060
- }}
3061
- >
3062
- {value ? <Icon name="check" size={12} color={t.color.textInverted} /> : null}
3063
- </Pressable>
3064
- );
3065
- }
3066
-
3067
3224
  // SelectionBar appears above the entry list when rows are checked; it offers a
3068
3225
  // bulk move and/or delete plus a clear action (each shown only when wired).
3069
3226
  function SelectionBar({ count, onMove, onDelete, onClear }: { count: number; onMove?: () => void; onDelete?: () => void; onClear: () => void }) {
@@ -3206,7 +3363,7 @@ function EntriesList({
3206
3363
  <View style={{ flexDirection: "row", alignItems: "center" }}>
3207
3364
  {showChecks ? (
3208
3365
  <View style={{ paddingLeft: t.space(4) }}>
3209
- <CheckBox value={!!checked?.[e.id]} onToggle={() => onToggleEntry?.(e.id)} />
3366
+ <CheckBox value={!!checked?.[e.id]} onToggle={() => onToggleEntry?.(e.id)} testID="entry-checkbox" />
3210
3367
  </View>
3211
3368
  ) : null}
3212
3369
  <View style={{ flex: 1 }}>
@@ -3970,6 +4127,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
3970
4127
  <EntryDetail
3971
4128
  entry={col.entry}
3972
4129
  documentActions={props.documentActions}
4130
+ history={props.history}
3973
4131
  onEntryDraft={props.onEntryDraft}
3974
4132
  renderField={renderField}
3975
4133
  onSaveEntry={props.onSaveEntry}
@@ -4845,6 +5003,7 @@ function MobileBrowser(props: ContentBrowserProps) {
4845
5003
  <EntryDetail
4846
5004
  entry={entry}
4847
5005
  documentActions={props.documentActions}
5006
+ history={props.history}
4848
5007
  onEntryDraft={props.onEntryDraft}
4849
5008
  renderField={renderField}
4850
5009
  onSaveEntry={props.onSaveEntry}