@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.
- package/package.json +1 -1
- package/src/__tests__/ContentBrowser.history.test.tsx +385 -0
- package/src/__tests__/ContentBrowser.historycollab.test.tsx +433 -0
- package/src/components/ChangeDetail.tsx +94 -11
- package/src/components/CollabField.tsx +71 -0
- package/src/components/ContentBrowser.tsx +200 -41
- package/src/components/DocumentHistory.tsx +408 -0
- package/src/components/primitives.tsx +46 -1
- package/src/history.ts +82 -0
- package/src/index.ts +5 -1
|
@@ -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
|
-
{/*
|
|
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
|
-
|
|
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}
|