@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.
- 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
|
@@ -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";
|
|
@@ -15,6 +15,7 @@ import { BranchMenu, type BranchRef } from "./BranchMenu";
|
|
|
15
15
|
import { ProjectMenu, type ProjectRef } from "./ProjectMenu";
|
|
16
16
|
import { ChangeDetail, type DocumentChange, type FieldConflict, type ConflictChoice } from "./ChangeDetail";
|
|
17
17
|
import { ApplyChangesModal, type MergeProgress } from "./ApplyChangesModal";
|
|
18
|
+
import { ProtectedBranchModal } from "./ProtectedBranchModal";
|
|
18
19
|
import { NotificationBell, type NotificationItem } from "./Notifications";
|
|
19
20
|
import {
|
|
20
21
|
CollabInput,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
PresenceField,
|
|
23
24
|
PresenceAvatars,
|
|
24
25
|
useCollabRegister,
|
|
26
|
+
writeCollabValue,
|
|
25
27
|
useCollabMirror,
|
|
26
28
|
useCollabSynced,
|
|
27
29
|
useCollabValue,
|
|
@@ -31,6 +33,10 @@ import {
|
|
|
31
33
|
import { clamp, reorder, slotAtX } from "./reorder";
|
|
32
34
|
import { MediaField, MediaProvider, DocumentPathProvider } from "./MediaField";
|
|
33
35
|
import { MediaBrowser } from "./MediaBrowser";
|
|
36
|
+
import { FormsBrowser } from "./FormsBrowser";
|
|
37
|
+
import { FORMS_NAV_KEY, formsNavKey, isFormsNavKey, parseFormsNavKey, type FormsApi } from "../forms";
|
|
38
|
+
import { type HistoryApi } from "../history";
|
|
39
|
+
import { DocumentHistory } from "./DocumentHistory";
|
|
34
40
|
import {
|
|
35
41
|
MEDIA_NAV_KEY,
|
|
36
42
|
mediaNavKey,
|
|
@@ -109,6 +115,12 @@ export type FieldSlotArgs = {
|
|
|
109
115
|
// CRDT and publish presence.
|
|
110
116
|
collab?: CollabApi;
|
|
111
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;
|
|
112
124
|
};
|
|
113
125
|
export type RenderField = (args: FieldSlotArgs) => React.ReactNode;
|
|
114
126
|
|
|
@@ -334,6 +346,22 @@ export type ContentBrowserProps = {
|
|
|
334
346
|
// store gets.
|
|
335
347
|
media?: MediaApi;
|
|
336
348
|
|
|
349
|
+
// Forms data seam, the same split (docs/forms.md §10.2). Omitted → the Forms
|
|
350
|
+
// surface never renders, which is what a config declaring no forms looks like.
|
|
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;
|
|
360
|
+
// The selected submission's id, and the reporter for taps — bound to the URL
|
|
361
|
+
// by the host exactly as document selection is.
|
|
362
|
+
selectedSubmissionId?: string | null;
|
|
363
|
+
onSelectSubmission?: (id: string | null) => void;
|
|
364
|
+
|
|
337
365
|
// When provided, field/body edits autosave 1s after the last keystroke and an
|
|
338
366
|
// autosave indicator appears in the detail header. Omitted → edits stay local
|
|
339
367
|
// (the demo/read-only behavior).
|
|
@@ -468,6 +496,19 @@ export type ContentBrowserProps = {
|
|
|
468
496
|
changeRequestOpen?: boolean;
|
|
469
497
|
changeRequestUrl?: string;
|
|
470
498
|
creatingChangeRequest?: boolean;
|
|
499
|
+
|
|
500
|
+
// Protected-branch save prompt. The server refuses a save to a branch the
|
|
501
|
+
// provider protects; the host catches that, sets protectedBranch to the branch
|
|
502
|
+
// name, and this modal offers the one thing that resolves it — a branch to put
|
|
503
|
+
// the edit on. onCreateBranchAndSave receives the name; the host creates the
|
|
504
|
+
// branch and applies the pending edit to it in one server call.
|
|
505
|
+
protectedBranch?: string;
|
|
506
|
+
protectedBranchDocument?: string;
|
|
507
|
+
protectedBranchSuggestedName?: string;
|
|
508
|
+
creatingProtectedBranch?: boolean;
|
|
509
|
+
protectedBranchError?: string | null;
|
|
510
|
+
onCreateBranchAndSave?: (name: string) => void;
|
|
511
|
+
onCancelProtectedSave?: () => void;
|
|
471
512
|
onCreateChangeRequest?: (explanation: string) => void;
|
|
472
513
|
onViewChangeRequest?: () => void;
|
|
473
514
|
|
|
@@ -733,6 +774,11 @@ type FieldControlProps = {
|
|
|
733
774
|
// When this number changes, the field programmatically focuses itself (used to
|
|
734
775
|
// jump to a peer's field from its presence avatar).
|
|
735
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;
|
|
736
782
|
};
|
|
737
783
|
|
|
738
784
|
// The scalar type an array's `of` maps to when rendering item controls.
|
|
@@ -746,7 +792,7 @@ function ofToType(of?: string): string {
|
|
|
746
792
|
}
|
|
747
793
|
|
|
748
794
|
function FieldControl(props: FieldControlProps) {
|
|
749
|
-
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;
|
|
750
796
|
const t = useTheme();
|
|
751
797
|
const label = hideLabel ? "" : field.label || field.name;
|
|
752
798
|
// A text field participates in live collaboration when the document has a
|
|
@@ -759,7 +805,7 @@ function FieldControl(props: FieldControlProps) {
|
|
|
759
805
|
|
|
760
806
|
// Host-supplied control (e.g. the markdown editor) takes precedence.
|
|
761
807
|
if (renderField) {
|
|
762
|
-
const custom = renderField({ field, value, onChange, readOnly, collab: collabText, path });
|
|
808
|
+
const custom = renderField({ field, value, onChange, readOnly, collab: collabText, path, resetNonce });
|
|
763
809
|
if (custom != null && custom !== false) {
|
|
764
810
|
return (
|
|
765
811
|
<View style={{ gap: t.space(2) }}>
|
|
@@ -2557,6 +2603,7 @@ function EntryDetail({
|
|
|
2557
2603
|
onMove,
|
|
2558
2604
|
collectionPath,
|
|
2559
2605
|
folderOptions,
|
|
2606
|
+
history,
|
|
2560
2607
|
active,
|
|
2561
2608
|
onActivate,
|
|
2562
2609
|
onCollapse,
|
|
@@ -2579,6 +2626,10 @@ function EntryDetail({
|
|
|
2579
2626
|
collectionPath?: string;
|
|
2580
2627
|
// Existing folders (relative to the glob base) offered by the move autocomplete.
|
|
2581
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;
|
|
2582
2633
|
// Desktop columns: `active` marks the focused column (accent + header press
|
|
2583
2634
|
// activates it via onActivate); onCollapse/onClose add the header rail/close
|
|
2584
2635
|
// controls. All omitted on mobile → unchanged single-detail behavior.
|
|
@@ -2599,6 +2650,23 @@ function EntryDetail({
|
|
|
2599
2650
|
const canRename = !readOnly && !!onRename;
|
|
2600
2651
|
const canMove = !readOnly && !!onMove && glob.hierarchical;
|
|
2601
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
|
+
}
|
|
2602
2670
|
|
|
2603
2671
|
// Both branches share one value map. The title+body branch is modeled as a
|
|
2604
2672
|
// single body-source field so autosave logic is uniform.
|
|
@@ -2781,6 +2849,112 @@ function EntryDetail({
|
|
|
2781
2849
|
[commit],
|
|
2782
2850
|
);
|
|
2783
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
|
+
|
|
2784
2958
|
// A field edit within the active group-drill frame: write the value at the
|
|
2785
2959
|
// frame's nested path, then commit the whole map (buildChange serializes it).
|
|
2786
2960
|
const changeInFrame = useCallback(
|
|
@@ -2844,19 +3018,7 @@ function EntryDetail({
|
|
|
2844
3018
|
{saveHandler && dirty ? (
|
|
2845
3019
|
<Pressable
|
|
2846
3020
|
testID="discard-changes"
|
|
2847
|
-
onPress={
|
|
2848
|
-
const restored = seed();
|
|
2849
|
-
valuesRef.current = restored;
|
|
2850
|
-
setValues(restored);
|
|
2851
|
-
setTouched({});
|
|
2852
|
-
// The preview is rendering the draft that is being thrown away,
|
|
2853
|
-
// and nothing else will tell it otherwise: it is fed from edits,
|
|
2854
|
-
// and a discard is the one change to a document that produces no
|
|
2855
|
-
// edit. Without this the preview keeps showing work the author
|
|
2856
|
-
// just deleted, until they type again.
|
|
2857
|
-
emitDraft(buildChange(entry.id, editFields, restored), entry);
|
|
2858
|
-
discard();
|
|
2859
|
-
}}
|
|
3021
|
+
onPress={discardEdits}
|
|
2860
3022
|
style={{ paddingHorizontal: t.space(2), paddingVertical: t.space(1) }}
|
|
2861
3023
|
>
|
|
2862
3024
|
<Text variant="monoSm" color="tertiary">Discard</Text>
|
|
@@ -2884,6 +3046,20 @@ function EntryDetail({
|
|
|
2884
3046
|
document's own actions, while the menu holds the destructive and
|
|
2885
3047
|
path-changing ones that should stay one level down. */}
|
|
2886
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}
|
|
2887
3063
|
{canRename || canMove || (canDelete && onDeleteEntry) ? (
|
|
2888
3064
|
<DetailMenu
|
|
2889
3065
|
onRename={canRename ? () => setPrompt("rename") : undefined}
|
|
@@ -2899,10 +3075,35 @@ function EntryDetail({
|
|
|
2899
3075
|
) : null}
|
|
2900
3076
|
</View>
|
|
2901
3077
|
|
|
2902
|
-
{/*
|
|
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
|
|
2903
3104
|
fresh initial values (and the scroll resets to top) when the selected
|
|
2904
|
-
document changes. The header above stays put; only the body scrolls. */
|
|
2905
|
-
|
|
3105
|
+
document changes. The header above stays put; only the body scrolls. */
|
|
3106
|
+
hasFields ? (
|
|
2906
3107
|
// The document's path reaches media fields at any nesting depth through
|
|
2907
3108
|
// context: a picker inside a group or a list item needs it to interpret
|
|
2908
3109
|
// relative references, and drilling it through every level would touch
|
|
@@ -2940,6 +3141,7 @@ function EntryDetail({
|
|
|
2940
3141
|
collab={entry.collab}
|
|
2941
3142
|
path={[...groupPath, f.name].join(".")}
|
|
2942
3143
|
focusSignal={focusTarget && focusTarget.name === f.name ? focusTarget.nonce : undefined}
|
|
3144
|
+
resetNonce={resetNonces[f.name]}
|
|
2943
3145
|
/>
|
|
2944
3146
|
))}
|
|
2945
3147
|
</ScrollView>
|
|
@@ -3019,27 +3221,6 @@ function useMultiSelect(resetKey: string) {
|
|
|
3019
3221
|
return { checked, toggle, clear, checkedIds, checkedFolders, toggleFolder, checkedFolderKeys };
|
|
3020
3222
|
}
|
|
3021
3223
|
|
|
3022
|
-
// CheckBox is the row-selection control for multi-select.
|
|
3023
|
-
function CheckBox({ value, onToggle }: { value: boolean; onToggle: () => void }) {
|
|
3024
|
-
const t = useTheme();
|
|
3025
|
-
return (
|
|
3026
|
-
<Pressable
|
|
3027
|
-
// Stop the press from bubbling to the row's open handler (the checkbox
|
|
3028
|
-
// sits inside the row Pressable) so toggling never also opens the entry.
|
|
3029
|
-
onPress={(e?: { stopPropagation?: () => void }) => { e?.stopPropagation?.(); onToggle(); }}
|
|
3030
|
-
testID="entry-checkbox"
|
|
3031
|
-
style={{
|
|
3032
|
-
width: 18, height: 18, borderRadius: t.radius.sm, borderWidth: 1,
|
|
3033
|
-
borderColor: value ? t.color.borderStrong : t.color.borderDefault,
|
|
3034
|
-
backgroundColor: value ? t.color.surfaceInverted : t.color.surfaceRaised,
|
|
3035
|
-
alignItems: "center", justifyContent: "center",
|
|
3036
|
-
}}
|
|
3037
|
-
>
|
|
3038
|
-
{value ? <Icon name="check" size={12} color={t.color.textInverted} /> : null}
|
|
3039
|
-
</Pressable>
|
|
3040
|
-
);
|
|
3041
|
-
}
|
|
3042
|
-
|
|
3043
3224
|
// SelectionBar appears above the entry list when rows are checked; it offers a
|
|
3044
3225
|
// bulk move and/or delete plus a clear action (each shown only when wired).
|
|
3045
3226
|
function SelectionBar({ count, onMove, onDelete, onClear }: { count: number; onMove?: () => void; onDelete?: () => void; onClear: () => void }) {
|
|
@@ -3182,7 +3363,7 @@ function EntriesList({
|
|
|
3182
3363
|
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
|
3183
3364
|
{showChecks ? (
|
|
3184
3365
|
<View style={{ paddingLeft: t.space(4) }}>
|
|
3185
|
-
<CheckBox value={!!checked?.[e.id]} onToggle={() => onToggleEntry?.(e.id)} />
|
|
3366
|
+
<CheckBox value={!!checked?.[e.id]} onToggle={() => onToggleEntry?.(e.id)} testID="entry-checkbox" />
|
|
3186
3367
|
</View>
|
|
3187
3368
|
) : null}
|
|
3188
3369
|
<View style={{ flex: 1 }}>
|
|
@@ -3557,9 +3738,28 @@ function ReadOnlyBanner({ notice, actionLabel, onAction }: { notice?: string; ac
|
|
|
3557
3738
|
);
|
|
3558
3739
|
}
|
|
3559
3740
|
|
|
3560
|
-
//
|
|
3561
|
-
//
|
|
3562
|
-
//
|
|
3741
|
+
// ModalOverlays renders the browser's full-screen modals — apply changes /
|
|
3742
|
+
// change request, and the protected-branch save prompt. Shared by both layouts
|
|
3743
|
+
// so every modal is reachable on mobile too.
|
|
3744
|
+
function ModalOverlays(props: ContentBrowserProps) {
|
|
3745
|
+
return (
|
|
3746
|
+
<>
|
|
3747
|
+
<ApplyModalOverlay {...props} />
|
|
3748
|
+
{props.protectedBranch ? (
|
|
3749
|
+
<ProtectedBranchModal
|
|
3750
|
+
branch={props.protectedBranch}
|
|
3751
|
+
documentLabel={props.protectedBranchDocument}
|
|
3752
|
+
suggestedName={props.protectedBranchSuggestedName}
|
|
3753
|
+
busy={props.creatingProtectedBranch}
|
|
3754
|
+
error={props.protectedBranchError}
|
|
3755
|
+
onCreate={props.onCreateBranchAndSave ?? (() => {})}
|
|
3756
|
+
onCancel={props.onCancelProtectedSave ?? (() => {})}
|
|
3757
|
+
/>
|
|
3758
|
+
) : null}
|
|
3759
|
+
</>
|
|
3760
|
+
);
|
|
3761
|
+
}
|
|
3762
|
+
|
|
3563
3763
|
function ApplyModalOverlay(props: ContentBrowserProps) {
|
|
3564
3764
|
if (!props.applyOpen) return null;
|
|
3565
3765
|
return (
|
|
@@ -3599,6 +3799,10 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
3599
3799
|
const mediaShowing = isMediaNavKey(activeNavKey);
|
|
3600
3800
|
const mediaSet = parseMediaNavKey(activeNavKey);
|
|
3601
3801
|
const mediaSets = props.media?.sets ?? [];
|
|
3802
|
+
// Forms occupy their own prefixed nav-key space, mirroring media's — a
|
|
3803
|
+
// collection name cannot contain a colon, so neither can collide.
|
|
3804
|
+
const formsShowing = isFormsNavKey(activeNavKey);
|
|
3805
|
+
const activeForm = parseFormsNavKey(activeNavKey);
|
|
3602
3806
|
// "Browsing" mode (collection-driven): nothing auto-selected, empty states shown.
|
|
3603
3807
|
const browsing = entriesEmpty !== undefined;
|
|
3604
3808
|
// Controlled selection: when the host wired onSelectEntry, selection lives in
|
|
@@ -3923,6 +4127,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
3923
4127
|
<EntryDetail
|
|
3924
4128
|
entry={col.entry}
|
|
3925
4129
|
documentActions={props.documentActions}
|
|
4130
|
+
history={props.history}
|
|
3926
4131
|
onEntryDraft={props.onEntryDraft}
|
|
3927
4132
|
renderField={renderField}
|
|
3928
4133
|
onSaveEntry={props.onSaveEntry}
|
|
@@ -4052,7 +4257,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4052
4257
|
const readOnlyBanner = (
|
|
4053
4258
|
<ReadOnlyBanner notice={props.readOnlyNotice} actionLabel={props.readOnlyNoticeActionLabel} onAction={props.onReadOnlyNoticeAction} />
|
|
4054
4259
|
);
|
|
4055
|
-
const
|
|
4260
|
+
const modalOverlays = <ModalOverlays {...props} />;
|
|
4056
4261
|
|
|
4057
4262
|
// The changes surface reuses the same three-pane model as Edit, so switching
|
|
4058
4263
|
// between them doesn't relayout the screen — only what each pane contains
|
|
@@ -4105,7 +4310,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4105
4310
|
conflicts={props.selectedConflicts}
|
|
4106
4311
|
onResolveConflict={props.onResolveConflict}
|
|
4107
4312
|
/>
|
|
4108
|
-
{
|
|
4313
|
+
{modalOverlays}
|
|
4109
4314
|
</AppShell>
|
|
4110
4315
|
);
|
|
4111
4316
|
}
|
|
@@ -4120,7 +4325,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4120
4325
|
<Pane flex={1} testID="pane-plugin" scroll={false}>
|
|
4121
4326
|
{props.contentSlot}
|
|
4122
4327
|
</Pane>
|
|
4123
|
-
{
|
|
4328
|
+
{modalOverlays}
|
|
4124
4329
|
</AppShell>
|
|
4125
4330
|
);
|
|
4126
4331
|
}
|
|
@@ -4129,6 +4334,60 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4129
4334
|
// lists the media sets exactly where a collection's documents would be, and the
|
|
4130
4335
|
// details pane holds the browser for whichever set is selected. Selecting a set
|
|
4131
4336
|
// goes through onSelectNav, so it lands in the URL like any other selection.
|
|
4337
|
+
// Forms enter through the same content column every other surface uses: the
|
|
4338
|
+
// list of forms sits exactly where a collection's documents would, at the
|
|
4339
|
+
// list width, with the empty state beside it. A list of half a dozen rows
|
|
4340
|
+
// stretched across everything after the sidebar reads as a different kind of
|
|
4341
|
+
// screen than Edit, Changes and Media, when it is the same kind of screen.
|
|
4342
|
+
//
|
|
4343
|
+
// Opening a form is where forms stop fitting the three-pane model, and so it
|
|
4344
|
+
// is where they leave it: that surface is two more levels deep (submissions →
|
|
4345
|
+
// one submission), and threading those through panes meant for collection →
|
|
4346
|
+
// document → editor would give a pane whose meaning changes with the level.
|
|
4347
|
+
// FormsBrowser owns its own split from there.
|
|
4348
|
+
if (formsShowing && props.forms) {
|
|
4349
|
+
if (!activeForm) {
|
|
4350
|
+
return (
|
|
4351
|
+
<AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
|
|
4352
|
+
{navPane}
|
|
4353
|
+
<ResizeHandle width={navW} min={180} max={420} onChange={setNavW} testID="resize-nav" />
|
|
4354
|
+
|
|
4355
|
+
<Pane width={listW} testID="pane-forms" scroll={false} header={<PaneTitle title="Forms" />}>
|
|
4356
|
+
<FormsBrowser
|
|
4357
|
+
api={props.forms}
|
|
4358
|
+
form={null}
|
|
4359
|
+
onSelectForm={(name) => onSelectNav(name ? formsNavKey(name) : FORMS_NAV_KEY)}
|
|
4360
|
+
variant="desktop"
|
|
4361
|
+
/>
|
|
4362
|
+
</Pane>
|
|
4363
|
+
<ResizeHandle width={listW} min={260} max={640} onChange={setListW} testID="resize-forms" />
|
|
4364
|
+
|
|
4365
|
+
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
|
|
4366
|
+
<Text variant="body" color="tertiary" testID="forms-empty">Select a form</Text>
|
|
4367
|
+
</View>
|
|
4368
|
+
{modalOverlays}
|
|
4369
|
+
</AppShell>
|
|
4370
|
+
);
|
|
4371
|
+
}
|
|
4372
|
+
return (
|
|
4373
|
+
<AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
|
|
4374
|
+
{navPane}
|
|
4375
|
+
<ResizeHandle width={navW} min={180} max={420} onChange={setNavW} testID="resize-nav" />
|
|
4376
|
+
<Pane flex={1} testID="pane-forms" scroll={false}>
|
|
4377
|
+
<FormsBrowser
|
|
4378
|
+
api={props.forms}
|
|
4379
|
+
form={activeForm}
|
|
4380
|
+
onSelectForm={(name) => onSelectNav(name ? formsNavKey(name) : FORMS_NAV_KEY)}
|
|
4381
|
+
selectedId={props.selectedSubmissionId ?? null}
|
|
4382
|
+
onSelectSubmission={props.onSelectSubmission}
|
|
4383
|
+
variant="desktop"
|
|
4384
|
+
/>
|
|
4385
|
+
</Pane>
|
|
4386
|
+
{modalOverlays}
|
|
4387
|
+
</AppShell>
|
|
4388
|
+
);
|
|
4389
|
+
}
|
|
4390
|
+
|
|
4132
4391
|
if (mediaShowing) {
|
|
4133
4392
|
return (
|
|
4134
4393
|
<AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
|
|
@@ -4151,7 +4410,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4151
4410
|
<Text variant="body" color="tertiary" testID="media-sets-empty">Select a media collection</Text>
|
|
4152
4411
|
</View>
|
|
4153
4412
|
)}
|
|
4154
|
-
{
|
|
4413
|
+
{modalOverlays}
|
|
4155
4414
|
</AppShell>
|
|
4156
4415
|
);
|
|
4157
4416
|
}
|
|
@@ -4352,7 +4611,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4352
4611
|
onClose={() => setMovePrompt(false)}
|
|
4353
4612
|
/>
|
|
4354
4613
|
) : null}
|
|
4355
|
-
{
|
|
4614
|
+
{modalOverlays}
|
|
4356
4615
|
</AppShell>
|
|
4357
4616
|
);
|
|
4358
4617
|
}
|
|
@@ -4428,7 +4687,7 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4428
4687
|
const readOnlyBanner = (
|
|
4429
4688
|
<ReadOnlyBanner notice={props.readOnlyNotice} actionLabel={props.readOnlyNoticeActionLabel} onAction={props.onReadOnlyNoticeAction} />
|
|
4430
4689
|
);
|
|
4431
|
-
const
|
|
4690
|
+
const modalOverlays = <ModalOverlays {...props} />;
|
|
4432
4691
|
|
|
4433
4692
|
// Media drills the same way a collection does — nav → list → detail — so the
|
|
4434
4693
|
// back arrow means the same thing at every level. Pressing Media lists the
|
|
@@ -4436,6 +4695,8 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4436
4695
|
const mediaShowing = isMediaNavKey(activeNavKey);
|
|
4437
4696
|
const mediaSet = parseMediaNavKey(activeNavKey);
|
|
4438
4697
|
const mediaSets = props.media?.sets ?? [];
|
|
4698
|
+
const formsShowing = isFormsNavKey(activeNavKey);
|
|
4699
|
+
const activeForm = parseFormsNavKey(activeNavKey);
|
|
4439
4700
|
// A plugin screen renders as its own drilled-in screen; Back returns to the
|
|
4440
4701
|
// nav list like any other selection.
|
|
4441
4702
|
if (props.contentSlot != null) {
|
|
@@ -4455,6 +4716,40 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4455
4716
|
</MobileScreen>
|
|
4456
4717
|
);
|
|
4457
4718
|
}
|
|
4719
|
+
if (formsShowing && props.forms) {
|
|
4720
|
+
return (
|
|
4721
|
+
<MobileScreen
|
|
4722
|
+
testID="mobile-forms"
|
|
4723
|
+
header={
|
|
4724
|
+
<>
|
|
4725
|
+
{/* Back steps up one level, not out to the nav — the same rule the
|
|
4726
|
+
media and collection screens follow. */}
|
|
4727
|
+
<IconButton
|
|
4728
|
+
name="chevronLeft"
|
|
4729
|
+
onPress={() => (activeForm ? onSelectNav(FORMS_NAV_KEY) : backToNav())}
|
|
4730
|
+
size="md"
|
|
4731
|
+
label="Back"
|
|
4732
|
+
/>
|
|
4733
|
+
<Text variant="body" weight="semibold" style={{ flex: 1 }}>
|
|
4734
|
+
{activeForm
|
|
4735
|
+
? props.forms.forms.find((f) => f.name === activeForm)?.label || activeForm
|
|
4736
|
+
: "Forms"}
|
|
4737
|
+
</Text>
|
|
4738
|
+
</>
|
|
4739
|
+
}
|
|
4740
|
+
>
|
|
4741
|
+
<FormsBrowser
|
|
4742
|
+
api={props.forms}
|
|
4743
|
+
form={activeForm}
|
|
4744
|
+
onSelectForm={(name) => onSelectNav(name ? formsNavKey(name) : FORMS_NAV_KEY)}
|
|
4745
|
+
selectedId={props.selectedSubmissionId ?? null}
|
|
4746
|
+
onSelectSubmission={props.onSelectSubmission}
|
|
4747
|
+
variant="mobile"
|
|
4748
|
+
/>
|
|
4749
|
+
</MobileScreen>
|
|
4750
|
+
);
|
|
4751
|
+
}
|
|
4752
|
+
|
|
4458
4753
|
if (mediaShowing) {
|
|
4459
4754
|
if (!mediaSet) {
|
|
4460
4755
|
return (
|
|
@@ -4518,7 +4813,7 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4518
4813
|
testID="mobile-shell"
|
|
4519
4814
|
header={header}
|
|
4520
4815
|
title={props.surface === "changes" ? "Changes" : "Content"}
|
|
4521
|
-
overlay={
|
|
4816
|
+
overlay={modalOverlays}
|
|
4522
4817
|
>
|
|
4523
4818
|
{/* Edit / Changes surface toggle (fits its content) with the Apply button
|
|
4524
4819
|
to its right on the changes surface. The read-only notice sits below. */}
|
|
@@ -4553,8 +4848,11 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4553
4848
|
</View>
|
|
4554
4849
|
) : null}
|
|
4555
4850
|
{readOnlyBanner}
|
|
4556
|
-
{sections.map((section) => (
|
|
4557
|
-
|
|
4851
|
+
{sections.map((section, i) => (
|
|
4852
|
+
// Keyed with the index as a fallback: an untitled section renders its
|
|
4853
|
+
// button without a heading, and there is more than one of those now
|
|
4854
|
+
// (Media, Forms), so the title alone is not unique.
|
|
4855
|
+
<View key={section.title || `section-${i}`}>
|
|
4558
4856
|
<SectionLabel>{section.title}</SectionLabel>
|
|
4559
4857
|
{section.items.map((item, i) => (
|
|
4560
4858
|
<NavRow
|
|
@@ -4596,7 +4894,7 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4596
4894
|
testID="mobile-entries"
|
|
4597
4895
|
scroll={false}
|
|
4598
4896
|
banner={readOnlyBanner}
|
|
4599
|
-
overlay={
|
|
4897
|
+
overlay={modalOverlays}
|
|
4600
4898
|
header={
|
|
4601
4899
|
search.open ? (
|
|
4602
4900
|
<SearchHeaderBar search={search} showFilter={facetFields.length > 0} />
|
|
@@ -4677,7 +4975,7 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4677
4975
|
<MobileScreen
|
|
4678
4976
|
testID="mobile-entry"
|
|
4679
4977
|
banner={readOnlyBanner}
|
|
4680
|
-
overlay={
|
|
4978
|
+
overlay={modalOverlays}
|
|
4681
4979
|
header={
|
|
4682
4980
|
<>
|
|
4683
4981
|
<IconButton name="chevronLeft" onPress={backToEntries} size="md" label="Back" />
|
|
@@ -4705,6 +5003,7 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4705
5003
|
<EntryDetail
|
|
4706
5004
|
entry={entry}
|
|
4707
5005
|
documentActions={props.documentActions}
|
|
5006
|
+
history={props.history}
|
|
4708
5007
|
onEntryDraft={props.onEntryDraft}
|
|
4709
5008
|
renderField={renderField}
|
|
4710
5009
|
onSaveEntry={props.onSaveEntry}
|
|
@@ -4975,14 +5274,20 @@ function mediaSetLabel(sets: MediaSetInfo[], name: string): string {
|
|
|
4975
5274
|
return set ? titleCaseWord(set.name) : titleCaseWord(name);
|
|
4976
5275
|
}
|
|
4977
5276
|
|
|
4978
|
-
// PaneTitle is the content pane's heading
|
|
4979
|
-
//
|
|
5277
|
+
// PaneTitle is the content pane's heading for the surfaces with no controls
|
|
5278
|
+
// beside it — Changes, Media, Forms.
|
|
5279
|
+
//
|
|
5280
|
+
// Deliberately the same Text the collection header uses (`listHeader`), and
|
|
5281
|
+
// nothing else. Pane's header row already supplies the height, the horizontal
|
|
5282
|
+
// padding and the rule beneath, so the padded wrapper this used to add sat
|
|
5283
|
+
// inside that padding and pushed the title a step right of every collection's —
|
|
5284
|
+
// at `body` rather than `h3`, so it read a size smaller too. Switching between
|
|
5285
|
+
// Posts and Media moved the heading twice over.
|
|
4980
5286
|
function PaneTitle({ title }: { title: string }) {
|
|
4981
|
-
const t = useTheme();
|
|
4982
5287
|
return (
|
|
4983
|
-
<
|
|
4984
|
-
|
|
4985
|
-
</
|
|
5288
|
+
<Text variant="h3" weight="semibold" style={{ flex: 1 }}>
|
|
5289
|
+
{title}
|
|
5290
|
+
</Text>
|
|
4986
5291
|
);
|
|
4987
5292
|
}
|
|
4988
5293
|
|