@gogitcms/design-system 0.16.0-next.0 → 0.16.0-next.10
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__/ApplyChangesModal.test.tsx +48 -0
- package/src/__tests__/BranchDeletedModal.test.tsx +42 -0
- package/src/__tests__/ContentBrowser.fieldfocus.test.tsx +109 -0
- package/src/__tests__/ContentBrowser.forms.test.tsx +157 -0
- package/src/__tests__/ContentBrowser.history.test.tsx +385 -0
- package/src/__tests__/ContentBrowser.historycollab.test.tsx +433 -0
- package/src/__tests__/ContentBrowser.slotmedia.test.tsx +150 -0
- package/src/__tests__/FormsBrowser.test.tsx +7 -2
- package/src/__tests__/MediaField.test.tsx +18 -0
- package/src/__tests__/ProtectedBranchModal.test.tsx +68 -0
- package/src/components/ApplyChangesModal.tsx +70 -21
- package/src/components/BranchDeletedModal.tsx +111 -0
- package/src/components/ChangeDetail.tsx +94 -11
- package/src/components/CollabField.tsx +71 -0
- package/src/components/ContentBrowser.tsx +440 -71
- package/src/components/DocumentHistory.tsx +408 -0
- package/src/components/FormsBrowser.tsx +3 -5
- package/src/components/MediaField.tsx +62 -0
- package/src/components/MediaPreview.tsx +14 -1
- package/src/components/MediaPreview.web.tsx +18 -3
- package/src/components/Notifications.tsx +146 -28
- package/src/components/ProtectedBranchModal.tsx +136 -0
- package/src/components/primitives.tsx +46 -1
- package/src/history.ts +82 -0
- package/src/icons.ts +1 -0
- package/src/index.ts +13 -3
- package/src/media.ts +15 -0
|
@@ -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,
|
|
@@ -29,15 +31,18 @@ import {
|
|
|
29
31
|
useCollabPeersUnder,
|
|
30
32
|
} from "./CollabField";
|
|
31
33
|
import { clamp, reorder, slotAtX } from "./reorder";
|
|
32
|
-
import { MediaField, MediaProvider, DocumentPathProvider } from "./MediaField";
|
|
34
|
+
import { MediaField, MediaProvider, DocumentPathProvider, useFieldMedia } from "./MediaField";
|
|
33
35
|
import { MediaBrowser } from "./MediaBrowser";
|
|
34
36
|
import { FormsBrowser } from "./FormsBrowser";
|
|
35
37
|
import { FORMS_NAV_KEY, formsNavKey, isFormsNavKey, parseFormsNavKey, type FormsApi } from "../forms";
|
|
38
|
+
import { type HistoryApi } from "../history";
|
|
39
|
+
import { DocumentHistory } from "./DocumentHistory";
|
|
36
40
|
import {
|
|
37
41
|
MEDIA_NAV_KEY,
|
|
38
42
|
mediaNavKey,
|
|
39
43
|
isMediaNavKey,
|
|
40
44
|
parseMediaNavKey,
|
|
45
|
+
type FieldMediaApi,
|
|
41
46
|
type MediaApi,
|
|
42
47
|
type MediaSetInfo,
|
|
43
48
|
type StoreAs,
|
|
@@ -111,6 +116,16 @@ export type FieldSlotArgs = {
|
|
|
111
116
|
// CRDT and publish presence.
|
|
112
117
|
collab?: CollabApi;
|
|
113
118
|
path?: string;
|
|
119
|
+
// Bumped when the host replaces this field's value outright rather than the
|
|
120
|
+
// user editing it — restoring it from a past version. A controlled control
|
|
121
|
+
// needs nothing from this (it already re-renders with the new value), but the
|
|
122
|
+
// body editor does: in a live session it ignores `value` and mirrors the
|
|
123
|
+
// shared fragment, so "the value changed" is not a thing it can observe.
|
|
124
|
+
resetNonce?: number;
|
|
125
|
+
// Media access scoped to this field — open the picker, resolve the paths the
|
|
126
|
+
// document holds — so a host control can embed and display images from the
|
|
127
|
+
// media library. Absent when the host provides no media api.
|
|
128
|
+
media?: FieldMediaApi;
|
|
114
129
|
};
|
|
115
130
|
export type RenderField = (args: FieldSlotArgs) => React.ReactNode;
|
|
116
131
|
|
|
@@ -326,6 +341,15 @@ export type ContentBrowserProps = {
|
|
|
326
341
|
// already separated from the frontmatter.
|
|
327
342
|
onEntryDraft?: (draft: EntrySaveChange, entry: CmsEntry) => void;
|
|
328
343
|
|
|
344
|
+
// Which field of an open document has keyboard focus, as the author moves
|
|
345
|
+
// between controls: the field's dotted path (`title`, `seo.description`,
|
|
346
|
+
// `blocks.0.heading`), or null when nothing in that document is focused.
|
|
347
|
+
// Web only — it reads the DOM's focus events. This is what lets a preview
|
|
348
|
+
// ring the part of the page the author is editing (§4.4 of the preview
|
|
349
|
+
// design), and it is separate from live collaboration: a lone author gets
|
|
350
|
+
// it too.
|
|
351
|
+
onFieldFocus?: (entryId: string, path: string | null) => void;
|
|
352
|
+
|
|
329
353
|
// Optional per-field renderer. When it returns a node for a field, that node
|
|
330
354
|
// replaces the built-in control (used to inject the markdown body editor).
|
|
331
355
|
renderField?: RenderField;
|
|
@@ -339,6 +363,14 @@ export type ContentBrowserProps = {
|
|
|
339
363
|
// Forms data seam, the same split (docs/forms.md §10.2). Omitted → the Forms
|
|
340
364
|
// surface never renders, which is what a config declaring no forms looks like.
|
|
341
365
|
forms?: FormsApi;
|
|
366
|
+
|
|
367
|
+
// Document-history data seam, the same split again: the DS owns the version
|
|
368
|
+
// browser and its ephemeral state, the host owns fetching. Provided → an open
|
|
369
|
+
// document's header gains a history button, and pressing it gives that
|
|
370
|
+
// column's body over to the version browser. Omitted → no affordance at all,
|
|
371
|
+
// which is what a deployment with no provider (local mode, desktop) gets: a
|
|
372
|
+
// feature the user never sees beats one that errors when clicked.
|
|
373
|
+
history?: HistoryApi;
|
|
342
374
|
// The selected submission's id, and the reporter for taps — bound to the URL
|
|
343
375
|
// by the host exactly as document selection is.
|
|
344
376
|
selectedSubmissionId?: string | null;
|
|
@@ -465,6 +497,9 @@ export type ContentBrowserProps = {
|
|
|
465
497
|
canApplyChanges?: boolean;
|
|
466
498
|
applyOpen?: boolean;
|
|
467
499
|
applySummary?: string;
|
|
500
|
+
// Why applying opens a change request instead of merging (see
|
|
501
|
+
// ApplyChangesModal.reviewReason). Absent for a direct merge.
|
|
502
|
+
applyReviewReason?: string;
|
|
468
503
|
applyProgress?: MergeProgress;
|
|
469
504
|
applyConflictCount?: number;
|
|
470
505
|
onConfirmApply?: () => void;
|
|
@@ -478,6 +513,19 @@ export type ContentBrowserProps = {
|
|
|
478
513
|
changeRequestOpen?: boolean;
|
|
479
514
|
changeRequestUrl?: string;
|
|
480
515
|
creatingChangeRequest?: boolean;
|
|
516
|
+
|
|
517
|
+
// Protected-branch save prompt. The server refuses a save to a branch the
|
|
518
|
+
// provider protects; the host catches that, sets protectedBranch to the branch
|
|
519
|
+
// name, and this modal offers the one thing that resolves it — a branch to put
|
|
520
|
+
// the edit on. onCreateBranchAndSave receives the name; the host creates the
|
|
521
|
+
// branch and applies the pending edit to it in one server call.
|
|
522
|
+
protectedBranch?: string;
|
|
523
|
+
protectedBranchDocument?: string;
|
|
524
|
+
protectedBranchSuggestedName?: string;
|
|
525
|
+
creatingProtectedBranch?: boolean;
|
|
526
|
+
protectedBranchError?: string | null;
|
|
527
|
+
onCreateBranchAndSave?: (name: string) => void;
|
|
528
|
+
onCancelProtectedSave?: () => void;
|
|
481
529
|
onCreateChangeRequest?: (explanation: string) => void;
|
|
482
530
|
onViewChangeRequest?: () => void;
|
|
483
531
|
|
|
@@ -743,8 +791,45 @@ type FieldControlProps = {
|
|
|
743
791
|
// When this number changes, the field programmatically focuses itself (used to
|
|
744
792
|
// jump to a peer's field from its presence avatar).
|
|
745
793
|
focusSignal?: number;
|
|
794
|
+
// Bumped when this field's value was replaced by the host rather than typed —
|
|
795
|
+
// restored from a past version. Only the host-supplied body editor needs it
|
|
796
|
+
// (see FieldSlotArgs.resetNonce); every built-in control is controlled and
|
|
797
|
+
// re-renders from `value` on its own.
|
|
798
|
+
resetNonce?: number;
|
|
799
|
+
// The field's full dotted path, when the caller knows better than
|
|
800
|
+
// "parent path + field name" — a list item, whose control is named after
|
|
801
|
+
// the list but sits at `list.<index>`.
|
|
802
|
+
fieldPath?: string;
|
|
746
803
|
};
|
|
747
804
|
|
|
805
|
+
// The dotted path of the field being rendered, for the controls under it:
|
|
806
|
+
// a group's children append their names to it, a list's items append their
|
|
807
|
+
// index. It exists so every control can carry its own path as a DOM
|
|
808
|
+
// attribute (`data-cms-field`) without any of them threading a prop through
|
|
809
|
+
// — the focus reporting in EntryDetail reads that attribute off whichever
|
|
810
|
+
// element the keyboard lands in.
|
|
811
|
+
//
|
|
812
|
+
// Deliberately separate from the collab `path` prop: that one is threaded
|
|
813
|
+
// only where a CRDT binding exists (top-level fields and drilled-in groups),
|
|
814
|
+
// whereas this is present for every field including list items, which the
|
|
815
|
+
// collab layer treats as one register.
|
|
816
|
+
const FieldPathContext = React.createContext<string>("");
|
|
817
|
+
|
|
818
|
+
function FieldControl(props: FieldControlProps) {
|
|
819
|
+
const prefix = React.useContext(FieldPathContext);
|
|
820
|
+
const fullPath = props.fieldPath ?? (prefix ? `${prefix}.${props.field.name}` : props.field.name);
|
|
821
|
+
return (
|
|
822
|
+
<FieldPathContext.Provider value={fullPath}>
|
|
823
|
+
<View
|
|
824
|
+
// @ts-expect-error react-native-web maps dataSet -> data-* attributes
|
|
825
|
+
dataSet={{ cmsField: fullPath }}
|
|
826
|
+
>
|
|
827
|
+
<FieldControlInner {...props} />
|
|
828
|
+
</View>
|
|
829
|
+
</FieldPathContext.Provider>
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
|
|
748
833
|
// The scalar type an array's `of` maps to when rendering item controls.
|
|
749
834
|
function ofToType(of?: string): string {
|
|
750
835
|
switch (of) {
|
|
@@ -755,8 +840,8 @@ function ofToType(of?: string): string {
|
|
|
755
840
|
}
|
|
756
841
|
}
|
|
757
842
|
|
|
758
|
-
function
|
|
759
|
-
const { field, value, onChange, renderField, readOnly = false, error, hideLabel, onOpenGroup, collab, path, focusSignal } = props;
|
|
843
|
+
function FieldControlInner(props: FieldControlProps) {
|
|
844
|
+
const { field, value, onChange, renderField, readOnly = false, error, hideLabel, onOpenGroup, collab, path, focusSignal, resetNonce } = props;
|
|
760
845
|
const t = useTheme();
|
|
761
846
|
const label = hideLabel ? "" : field.label || field.name;
|
|
762
847
|
// A text field participates in live collaboration when the document has a
|
|
@@ -767,9 +852,14 @@ function FieldControl(props: FieldControlProps) {
|
|
|
767
852
|
) : null;
|
|
768
853
|
const labelNode = label ? <Text variant="label" color="tertiary">{label}</Text> : null;
|
|
769
854
|
|
|
855
|
+
// Media for a host-supplied control. The picker element is mounted only when
|
|
856
|
+
// the slot actually renders this field, so a form full of built-in controls
|
|
857
|
+
// carries no extra modals.
|
|
858
|
+
const fieldMedia = useFieldMedia({ set: field.media, storeAs: field.storeAs });
|
|
859
|
+
|
|
770
860
|
// Host-supplied control (e.g. the markdown editor) takes precedence.
|
|
771
861
|
if (renderField) {
|
|
772
|
-
const custom = renderField({ field, value, onChange, readOnly, collab: collabText, path });
|
|
862
|
+
const custom = renderField({ field, value, onChange, readOnly, collab: collabText, path, resetNonce, media: fieldMedia.media });
|
|
773
863
|
if (custom != null && custom !== false) {
|
|
774
864
|
return (
|
|
775
865
|
<View style={{ gap: t.space(2) }}>
|
|
@@ -780,6 +870,7 @@ function FieldControl(props: FieldControlProps) {
|
|
|
780
870
|
custom
|
|
781
871
|
)}
|
|
782
872
|
{errorNode}
|
|
873
|
+
{fieldMedia.picker}
|
|
783
874
|
</View>
|
|
784
875
|
);
|
|
785
876
|
}
|
|
@@ -1592,11 +1683,14 @@ function ListControl({
|
|
|
1592
1683
|
...(field.media ? { component: "media", media: field.media, storeAs: field.storeAs } : {}),
|
|
1593
1684
|
};
|
|
1594
1685
|
|
|
1686
|
+
// The list's own path, from the FieldControl wrapping this control; an item
|
|
1687
|
+
// sits at `<list>.<index>`.
|
|
1688
|
+
const listPath = React.useContext(FieldPathContext);
|
|
1595
1689
|
return (
|
|
1596
1690
|
<View style={{ gap: t.space(2) }}>
|
|
1597
1691
|
{items.map((it, i) => (
|
|
1598
1692
|
<ItemFrame key={i} index={i} count={items.length} readOnly={readOnly || !canRemove} onRemove={() => removeItem(i)} onMove={(d) => moveItem(i, d)}>
|
|
1599
|
-
<FieldControl field={itemField} value={it} onChange={(v) => setItem(i, v)} renderField={renderField} readOnly={readOnly} hideLabel />
|
|
1693
|
+
<FieldControl field={itemField} value={it} onChange={(v) => setItem(i, v)} renderField={renderField} readOnly={readOnly} hideLabel fieldPath={listPath ? `${listPath}.${i}` : String(i)} />
|
|
1600
1694
|
</ItemFrame>
|
|
1601
1695
|
))}
|
|
1602
1696
|
{!readOnly && canAdd ? (
|
|
@@ -1636,15 +1730,25 @@ function MixedListControl({
|
|
|
1636
1730
|
onChange(next);
|
|
1637
1731
|
};
|
|
1638
1732
|
const addItem = () => onChange([...items, { [key]: variants[0]?.name ?? "" }]);
|
|
1733
|
+
const listPath = React.useContext(FieldPathContext);
|
|
1639
1734
|
|
|
1640
1735
|
return (
|
|
1641
1736
|
<View style={{ gap: t.space(2) }}>
|
|
1642
1737
|
{items.map((it, i) => {
|
|
1643
1738
|
const variantName = String(it?.[key] ?? "");
|
|
1644
1739
|
const variant = variants.find((v) => v.name === variantName);
|
|
1740
|
+
const itemPath = listPath ? `${listPath}.${i}` : String(i);
|
|
1645
1741
|
return (
|
|
1646
1742
|
<ItemFrame key={i} index={i} count={items.length} readOnly={readOnly || !canRemove} onRemove={() => removeItem(i)} onMove={(d) => moveItem(i, d)}>
|
|
1647
|
-
|
|
1743
|
+
{/* The item's fields compose under `<list>.<index>`; the item
|
|
1744
|
+
itself carries that path so focusing its variant picker
|
|
1745
|
+
reports the item rather than the whole list. */}
|
|
1746
|
+
<FieldPathContext.Provider value={itemPath}>
|
|
1747
|
+
<View
|
|
1748
|
+
style={{ gap: t.space(2) }}
|
|
1749
|
+
// @ts-expect-error react-native-web maps dataSet -> data-* attributes
|
|
1750
|
+
dataSet={{ cmsField: itemPath }}
|
|
1751
|
+
>
|
|
1648
1752
|
<SelectControl
|
|
1649
1753
|
value={variantName}
|
|
1650
1754
|
options={variants.map((v) => v.name)}
|
|
@@ -1662,6 +1766,7 @@ function MixedListControl({
|
|
|
1662
1766
|
/>
|
|
1663
1767
|
) : null}
|
|
1664
1768
|
</View>
|
|
1769
|
+
</FieldPathContext.Provider>
|
|
1665
1770
|
</ItemFrame>
|
|
1666
1771
|
);
|
|
1667
1772
|
})}
|
|
@@ -1697,6 +1802,9 @@ function BodyField({
|
|
|
1697
1802
|
format: "markdown",
|
|
1698
1803
|
value,
|
|
1699
1804
|
};
|
|
1805
|
+
// The schema-less body names no media set; the picker opens on the first
|
|
1806
|
+
// configured one and writes public paths.
|
|
1807
|
+
const fieldMedia = useFieldMedia({});
|
|
1700
1808
|
// The slot's onChange is unknown-typed (plugin fields may emit any shape);
|
|
1701
1809
|
// a body control only ever emits markdown strings.
|
|
1702
1810
|
const custom = renderField?.({
|
|
@@ -1704,15 +1812,25 @@ function BodyField({
|
|
|
1704
1812
|
value,
|
|
1705
1813
|
onChange: (v) => onChange(typeof v === "string" ? v : String(v ?? "")),
|
|
1706
1814
|
readOnly,
|
|
1815
|
+
media: fieldMedia.media,
|
|
1707
1816
|
});
|
|
1708
1817
|
return (
|
|
1709
|
-
<View
|
|
1818
|
+
<View
|
|
1819
|
+
style={{ gap: t.space(2), flex: 1 }}
|
|
1820
|
+
// The schema-less body is the document's one field; a preview rings
|
|
1821
|
+
// it under the same name the schema form would give it.
|
|
1822
|
+
// @ts-expect-error react-native-web maps dataSet -> data-* attributes
|
|
1823
|
+
dataSet={{ cmsField: "body" }}
|
|
1824
|
+
>
|
|
1710
1825
|
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
|
1711
1826
|
<Text variant="label" color="tertiary">Body</Text>
|
|
1712
1827
|
<Text variant="monoSm" color="tertiary">Markdown</Text>
|
|
1713
1828
|
</View>
|
|
1714
1829
|
{custom != null && custom !== false ? (
|
|
1715
|
-
|
|
1830
|
+
<>
|
|
1831
|
+
{custom}
|
|
1832
|
+
{fieldMedia.picker}
|
|
1833
|
+
</>
|
|
1716
1834
|
) : (
|
|
1717
1835
|
<View
|
|
1718
1836
|
style={{
|
|
@@ -2558,6 +2676,7 @@ function EntryDetail({
|
|
|
2558
2676
|
entry,
|
|
2559
2677
|
documentActions,
|
|
2560
2678
|
onEntryDraft,
|
|
2679
|
+
onFieldFocus,
|
|
2561
2680
|
renderField,
|
|
2562
2681
|
onSaveEntry,
|
|
2563
2682
|
readOnly = false,
|
|
@@ -2567,6 +2686,7 @@ function EntryDetail({
|
|
|
2567
2686
|
onMove,
|
|
2568
2687
|
collectionPath,
|
|
2569
2688
|
folderOptions,
|
|
2689
|
+
history,
|
|
2570
2690
|
active,
|
|
2571
2691
|
onActivate,
|
|
2572
2692
|
onCollapse,
|
|
@@ -2579,6 +2699,8 @@ function EntryDetail({
|
|
|
2579
2699
|
documentActions?: (entry: CmsEntry) => React.ReactNode;
|
|
2580
2700
|
// Debounced in-flight draft (see ContentBrowserProps.onEntryDraft).
|
|
2581
2701
|
onEntryDraft?: (draft: EntrySaveChange, entry: CmsEntry) => void;
|
|
2702
|
+
// The focused field's path, or null (see ContentBrowserProps.onFieldFocus).
|
|
2703
|
+
onFieldFocus?: (entryId: string, path: string | null) => void;
|
|
2582
2704
|
renderField?: RenderField;
|
|
2583
2705
|
onSaveEntry?: SaveEntry;
|
|
2584
2706
|
readOnly?: boolean;
|
|
@@ -2589,6 +2711,10 @@ function EntryDetail({
|
|
|
2589
2711
|
collectionPath?: string;
|
|
2590
2712
|
// Existing folders (relative to the glob base) offered by the move autocomplete.
|
|
2591
2713
|
folderOptions?: string[];
|
|
2714
|
+
// The document-history seam. Provided → a history button appears in this
|
|
2715
|
+
// document's header and its column can show the version browser. Omitted →
|
|
2716
|
+
// the header renders exactly as it did before history existed.
|
|
2717
|
+
history?: HistoryApi;
|
|
2592
2718
|
// Desktop columns: `active` marks the focused column (accent + header press
|
|
2593
2719
|
// activates it via onActivate); onCollapse/onClose add the header rail/close
|
|
2594
2720
|
// controls. All omitted on mobile → unchanged single-detail behavior.
|
|
@@ -2609,6 +2735,23 @@ function EntryDetail({
|
|
|
2609
2735
|
const canRename = !readOnly && !!onRename;
|
|
2610
2736
|
const canMove = !readOnly && !!onMove && glob.hierarchical;
|
|
2611
2737
|
const [prompt, setPrompt] = useState<"rename" | "move" | null>(null);
|
|
2738
|
+
// Per-field "your value was replaced" counters, bumped by a restore. Only the
|
|
2739
|
+
// host-supplied body editor reads them (see FieldSlotArgs.resetNonce); every
|
|
2740
|
+
// built-in control is controlled and needs no telling.
|
|
2741
|
+
const [resetNonces, setResetNonces] = useState<Record<string, number>>({});
|
|
2742
|
+
|
|
2743
|
+
// Whether this column is showing the version browser instead of the form.
|
|
2744
|
+
// Deliberately per-column and ephemeral: history is something you open, read
|
|
2745
|
+
// and close, so it lives and dies with the column rather than in the URL.
|
|
2746
|
+
const [showHistory, setShowHistory] = useState(false);
|
|
2747
|
+
// A column reused for another document (the "preview tab" behavior) must not
|
|
2748
|
+
// carry the previous document's history view onto it — you asked to see that
|
|
2749
|
+
// document, not this one's past.
|
|
2750
|
+
const historyFor = useRef(entry.id);
|
|
2751
|
+
if (historyFor.current !== entry.id) {
|
|
2752
|
+
historyFor.current = entry.id;
|
|
2753
|
+
if (showHistory) setShowHistory(false);
|
|
2754
|
+
}
|
|
2612
2755
|
|
|
2613
2756
|
// Both branches share one value map. The title+body branch is modeled as a
|
|
2614
2757
|
// single body-source field so autosave logic is uniform.
|
|
@@ -2791,6 +2934,112 @@ function EntryDetail({
|
|
|
2791
2934
|
[commit],
|
|
2792
2935
|
);
|
|
2793
2936
|
|
|
2937
|
+
// Publish a value map the form did NOT arrive at by typing — a restore from a
|
|
2938
|
+
// past version, or a discard back to the saved document.
|
|
2939
|
+
//
|
|
2940
|
+
// Both used to write local state and stop there, which is only half the job in
|
|
2941
|
+
// a live session. The shared document is the source of truth there: every
|
|
2942
|
+
// collab control prefers its binding to the value handed to it, so a replaced
|
|
2943
|
+
// value was displayed for one frame and then overwritten by the room's
|
|
2944
|
+
// unchanged one, and no peer ever saw it. Anything that replaces values
|
|
2945
|
+
// wholesale has to come through here.
|
|
2946
|
+
//
|
|
2947
|
+
// `names` are the fields whose values were replaced; unchanged ones are left
|
|
2948
|
+
// alone so a replacement never churns the room (or nudges a peer's cursor)
|
|
2949
|
+
// over a value that did not move.
|
|
2950
|
+
const publishReplacement = useCallback(
|
|
2951
|
+
(previous: Record<string, unknown>, next: Record<string, unknown>, names: string[]) => {
|
|
2952
|
+
const moved = names.filter((name) => !sameValue(previous[name], next[name]));
|
|
2953
|
+
if (moved.length === 0) return;
|
|
2954
|
+
|
|
2955
|
+
if (entry.collab) {
|
|
2956
|
+
for (const name of moved) {
|
|
2957
|
+
// A body is a ProseMirror fragment rather than a field binding, so it
|
|
2958
|
+
// is not written here — its editor is told instead, by the nonce below.
|
|
2959
|
+
if (editFields.find((f) => f.name === name)?.source === "body") continue;
|
|
2960
|
+
writeCollabValue(entry.collab, name, previous[name], next[name]);
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
// Tell the replaced fields their value came from outside. Keyed per field
|
|
2965
|
+
// rather than one counter for the document: the body editor acts on this,
|
|
2966
|
+
// and replacing some unrelated field must not make it rebuild a body a
|
|
2967
|
+
// peer may be mid-sentence in.
|
|
2968
|
+
setResetNonces((prev) => {
|
|
2969
|
+
const bumped = { ...prev };
|
|
2970
|
+
for (const name of moved) bumped[name] = (bumped[name] ?? 0) + 1;
|
|
2971
|
+
return bumped;
|
|
2972
|
+
});
|
|
2973
|
+
},
|
|
2974
|
+
// editFields is derived from entry, captured by id.
|
|
2975
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2976
|
+
[entry.id, entry.collab],
|
|
2977
|
+
);
|
|
2978
|
+
|
|
2979
|
+
// Values taken from a past version and put back into the form.
|
|
2980
|
+
//
|
|
2981
|
+
// It is an edit like any other: the restored fields land in `values`, the
|
|
2982
|
+
// document goes unsaved, and the author saves it (or discards it) themselves.
|
|
2983
|
+
// Nothing is written to git here — history is a place you read, and rewriting
|
|
2984
|
+
// the document from it should still pass through the same review a typed edit
|
|
2985
|
+
// does.
|
|
2986
|
+
const restoreValues = useCallback(
|
|
2987
|
+
(restored: { fields: Record<string, unknown>; body?: string | null }) => {
|
|
2988
|
+
const previous = valuesRef.current;
|
|
2989
|
+
const next = { ...previous, ...restored.fields };
|
|
2990
|
+
// The body is a field in the form like any other; which one it is comes
|
|
2991
|
+
// from the schema (`source: "body"`), the same mapping buildChange uses in
|
|
2992
|
+
// the other direction. A document whose model has no body field simply
|
|
2993
|
+
// has no body to restore.
|
|
2994
|
+
const bodyField = "body" in restored ? editFields.find((f) => f.source === "body") : undefined;
|
|
2995
|
+
if (bodyField) next[bodyField.name] = restored.body ?? "";
|
|
2996
|
+
|
|
2997
|
+
const replaced = Object.keys(restored.fields);
|
|
2998
|
+
if (bodyField) replaced.push(bodyField.name);
|
|
2999
|
+
|
|
3000
|
+
// Touched under the first restored name so a restore that leaves a
|
|
3001
|
+
// required field empty shows its error immediately, rather than looking
|
|
3002
|
+
// saveable until Save is pressed.
|
|
3003
|
+
//
|
|
3004
|
+
// Committed BEFORE publishing: a collab control observing the room echoes
|
|
3005
|
+
// what it sees back through onChange, and that echo builds its next map by
|
|
3006
|
+
// spreading the current one. Publishing first would have it spread the
|
|
3007
|
+
// pre-restore map.
|
|
3008
|
+
commit(next, replaced[0] ?? SYNTHETIC_BODY);
|
|
3009
|
+
publishReplacement(previous, next, replaced);
|
|
3010
|
+
// Drilled-into groups address the pre-restore shape; a restored group
|
|
3011
|
+
// object can have different children entirely.
|
|
3012
|
+
setGroupPath([]);
|
|
3013
|
+
},
|
|
3014
|
+
// editFields is derived from entry, which commit already captures by id.
|
|
3015
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3016
|
+
[commit, entry.id, publishReplacement],
|
|
3017
|
+
);
|
|
3018
|
+
|
|
3019
|
+
// Throw away everything unsaved and go back to the document as stored.
|
|
3020
|
+
//
|
|
3021
|
+
// In a live session that is a change to the ROOM, not just to this screen: the
|
|
3022
|
+
// unsaved work is the collective state every client is looking at, and a
|
|
3023
|
+
// discard that only reverted the local form would leave the document it just
|
|
3024
|
+
// claimed to restore sitting in the CRDT, ready to come straight back.
|
|
3025
|
+
const discardEdits = useCallback(() => {
|
|
3026
|
+
const previous = valuesRef.current;
|
|
3027
|
+
const restored = seed();
|
|
3028
|
+
valuesRef.current = restored;
|
|
3029
|
+
setValues(restored);
|
|
3030
|
+
setTouched({});
|
|
3031
|
+
// The preview is rendering the draft being thrown away, and nothing else
|
|
3032
|
+
// will tell it otherwise: it is fed from edits, and a discard is the one
|
|
3033
|
+
// change to a document that produces no edit. Without this the preview
|
|
3034
|
+
// keeps showing work the author just deleted, until they type again.
|
|
3035
|
+
emitDraft(buildChange(entry.id, editFields, restored), entry);
|
|
3036
|
+
discard();
|
|
3037
|
+
publishReplacement(previous, restored, editFields.map((f) => f.name));
|
|
3038
|
+
setGroupPath([]);
|
|
3039
|
+
// seed/editFields derive from entry, captured by id.
|
|
3040
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3041
|
+
}, [entry.id, discard, emitDraft, publishReplacement]);
|
|
3042
|
+
|
|
2794
3043
|
// A field edit within the active group-drill frame: write the value at the
|
|
2795
3044
|
// frame's nested path, then commit the whole map (buildChange serializes it).
|
|
2796
3045
|
const changeInFrame = useCallback(
|
|
@@ -2805,8 +3054,54 @@ function EntryDetail({
|
|
|
2805
3054
|
// The field list + nested value object for the active frame.
|
|
2806
3055
|
const frame = resolveFrame(editFields, values, groupPath);
|
|
2807
3056
|
|
|
3057
|
+
// Which field has the keyboard. One pair of DOM focus listeners on the
|
|
3058
|
+
// column rather than an onFocus on every control: the controls are many
|
|
3059
|
+
// (inputs, selects, the ProseMirror body, plugin fields) and every one of
|
|
3060
|
+
// them renders inside the FieldControl wrapper that carries the path as
|
|
3061
|
+
// `data-cms-field` — so the element the focus landed in is enough.
|
|
3062
|
+
//
|
|
3063
|
+
// focusout fires before the next focusin, so a blur is reported a tick
|
|
3064
|
+
// late and cancelled if focus went straight to another field: moving
|
|
3065
|
+
// between two controls reads as one change, not a flicker through null.
|
|
3066
|
+
const focusRoot = useRef<View>(null);
|
|
3067
|
+
const fieldFocus = useRef(onFieldFocus);
|
|
3068
|
+
fieldFocus.current = onFieldFocus;
|
|
3069
|
+
const entryId = entry.id;
|
|
3070
|
+
useEffect(() => {
|
|
3071
|
+
if (Platform.OS !== "web") return;
|
|
3072
|
+
const node = focusRoot.current as unknown as HTMLElement | null;
|
|
3073
|
+
if (!node || typeof node.addEventListener !== "function") return;
|
|
3074
|
+
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
3075
|
+
let last: string | null = null;
|
|
3076
|
+
const report = (path: string | null) => {
|
|
3077
|
+
if (path === last) return;
|
|
3078
|
+
last = path;
|
|
3079
|
+
fieldFocus.current?.(entryId, path);
|
|
3080
|
+
};
|
|
3081
|
+
const onFocusIn = (ev: Event) => {
|
|
3082
|
+
if (pending) { clearTimeout(pending); pending = null; }
|
|
3083
|
+
const target = ev.target as Element | null;
|
|
3084
|
+
const el = target && typeof target.closest === "function" ? target.closest("[data-cms-field]") : null;
|
|
3085
|
+
report(el?.getAttribute("data-cms-field") || null);
|
|
3086
|
+
};
|
|
3087
|
+
const onFocusOut = () => {
|
|
3088
|
+
if (pending) clearTimeout(pending);
|
|
3089
|
+
pending = setTimeout(() => { pending = null; report(null); }, 0);
|
|
3090
|
+
};
|
|
3091
|
+
node.addEventListener("focusin", onFocusIn);
|
|
3092
|
+
node.addEventListener("focusout", onFocusOut);
|
|
3093
|
+
return () => {
|
|
3094
|
+
node.removeEventListener("focusin", onFocusIn);
|
|
3095
|
+
node.removeEventListener("focusout", onFocusOut);
|
|
3096
|
+
if (pending) clearTimeout(pending);
|
|
3097
|
+
// The column is going away with the field still focused: nothing in
|
|
3098
|
+
// this document has focus any more.
|
|
3099
|
+
if (last !== null) fieldFocus.current?.(entryId, null);
|
|
3100
|
+
};
|
|
3101
|
+
}, [entryId]);
|
|
3102
|
+
|
|
2808
3103
|
return (
|
|
2809
|
-
<View style={{ flex: 1 }}>
|
|
3104
|
+
<View style={{ flex: 1 }} ref={focusRoot}>
|
|
2810
3105
|
{/* breadcrumb + actions. position/zIndex lift this row (and the "..." menu
|
|
2811
3106
|
dropdown it hosts) above the content pane so the menu receives clicks. */}
|
|
2812
3107
|
<View
|
|
@@ -2854,19 +3149,7 @@ function EntryDetail({
|
|
|
2854
3149
|
{saveHandler && dirty ? (
|
|
2855
3150
|
<Pressable
|
|
2856
3151
|
testID="discard-changes"
|
|
2857
|
-
onPress={
|
|
2858
|
-
const restored = seed();
|
|
2859
|
-
valuesRef.current = restored;
|
|
2860
|
-
setValues(restored);
|
|
2861
|
-
setTouched({});
|
|
2862
|
-
// The preview is rendering the draft that is being thrown away,
|
|
2863
|
-
// and nothing else will tell it otherwise: it is fed from edits,
|
|
2864
|
-
// and a discard is the one change to a document that produces no
|
|
2865
|
-
// edit. Without this the preview keeps showing work the author
|
|
2866
|
-
// just deleted, until they type again.
|
|
2867
|
-
emitDraft(buildChange(entry.id, editFields, restored), entry);
|
|
2868
|
-
discard();
|
|
2869
|
-
}}
|
|
3152
|
+
onPress={discardEdits}
|
|
2870
3153
|
style={{ paddingHorizontal: t.space(2), paddingVertical: t.space(1) }}
|
|
2871
3154
|
>
|
|
2872
3155
|
<Text variant="monoSm" color="tertiary">Discard</Text>
|
|
@@ -2894,6 +3177,20 @@ function EntryDetail({
|
|
|
2894
3177
|
document's own actions, while the menu holds the destructive and
|
|
2895
3178
|
path-changing ones that should stay one level down. */}
|
|
2896
3179
|
{documentActions?.(entry)}
|
|
3180
|
+
{/* History is a read of this document, not a change to it, so it sits
|
|
3181
|
+
out here with the other non-destructive actions rather than in the
|
|
3182
|
+
menu beside Delete. It toggles: pressing it again returns the column
|
|
3183
|
+
to the form. */}
|
|
3184
|
+
{history ? (
|
|
3185
|
+
<IconButton
|
|
3186
|
+
name="history"
|
|
3187
|
+
size="sm"
|
|
3188
|
+
active={showHistory}
|
|
3189
|
+
label={showHistory ? "Close history" : "Document history"}
|
|
3190
|
+
onPress={() => setShowHistory((v) => !v)}
|
|
3191
|
+
testID="document-history-toggle"
|
|
3192
|
+
/>
|
|
3193
|
+
) : null}
|
|
2897
3194
|
{canRename || canMove || (canDelete && onDeleteEntry) ? (
|
|
2898
3195
|
<DetailMenu
|
|
2899
3196
|
onRename={canRename ? () => setPrompt("rename") : undefined}
|
|
@@ -2909,10 +3206,35 @@ function EntryDetail({
|
|
|
2909
3206
|
) : null}
|
|
2910
3207
|
</View>
|
|
2911
3208
|
|
|
2912
|
-
{/*
|
|
3209
|
+
{/* History takes over the column's body, keeping its header: the header
|
|
3210
|
+
is what says which document you are looking at, and it carries the
|
|
3211
|
+
control that got you here and gets you back. */}
|
|
3212
|
+
{showHistory && history ? (
|
|
3213
|
+
<DocumentHistory
|
|
3214
|
+
documentId={entry.id}
|
|
3215
|
+
documentPath={entry.path}
|
|
3216
|
+
history={history}
|
|
3217
|
+
onClose={() => setShowHistory(false)}
|
|
3218
|
+
// Restoring needs somewhere for the values to go: a document that is
|
|
3219
|
+
// read-only, or that this host never wired a save for, gets the
|
|
3220
|
+
// history with no selection boxes at all rather than a form it can
|
|
3221
|
+
// dirty and never save. (saveHandler is already undefined when
|
|
3222
|
+
// readOnly, so this covers both.)
|
|
3223
|
+
onRestore={
|
|
3224
|
+
!saveHandler
|
|
3225
|
+
? undefined
|
|
3226
|
+
: (restored) => {
|
|
3227
|
+
restoreValues(restored);
|
|
3228
|
+
// Back to the form, which is where the restored values now
|
|
3229
|
+
// are and where the decision to keep them is made.
|
|
3230
|
+
setShowHistory(false);
|
|
3231
|
+
}
|
|
3232
|
+
}
|
|
3233
|
+
/>
|
|
3234
|
+
) : /* Keyed by entry.id so controls (incl. the markdown editor) remount with
|
|
2913
3235
|
fresh initial values (and the scroll resets to top) when the selected
|
|
2914
|
-
document changes. The header above stays put; only the body scrolls. */
|
|
2915
|
-
|
|
3236
|
+
document changes. The header above stays put; only the body scrolls. */
|
|
3237
|
+
hasFields ? (
|
|
2916
3238
|
// The document's path reaches media fields at any nesting depth through
|
|
2917
3239
|
// context: a picker inside a group or a list item needs it to interpret
|
|
2918
3240
|
// relative references, and drilling it through every level would touch
|
|
@@ -2935,6 +3257,8 @@ function EntryDetail({
|
|
|
2935
3257
|
<Text variant="monoSm" color="tertiary">{frame.labels.join(" / ")}</Text>
|
|
2936
3258
|
</Pressable>
|
|
2937
3259
|
) : null}
|
|
3260
|
+
{/* Drilled into a group, every field's path starts with the group's. */}
|
|
3261
|
+
<FieldPathContext.Provider value={groupPath.join(".")}>
|
|
2938
3262
|
{frame.fields.map((f) => (
|
|
2939
3263
|
<FieldControl
|
|
2940
3264
|
key={f.name}
|
|
@@ -2950,11 +3274,16 @@ function EntryDetail({
|
|
|
2950
3274
|
collab={entry.collab}
|
|
2951
3275
|
path={[...groupPath, f.name].join(".")}
|
|
2952
3276
|
focusSignal={focusTarget && focusTarget.name === f.name ? focusTarget.nonce : undefined}
|
|
3277
|
+
resetNonce={resetNonces[f.name]}
|
|
2953
3278
|
/>
|
|
2954
3279
|
))}
|
|
3280
|
+
</FieldPathContext.Provider>
|
|
2955
3281
|
</ScrollView>
|
|
2956
3282
|
</DocumentPathProvider>
|
|
2957
3283
|
) : (
|
|
3284
|
+
// The body editor resolves the images it embeds relative to this
|
|
3285
|
+
// document, the same way a media field does.
|
|
3286
|
+
<DocumentPathProvider path={entry.path}>
|
|
2958
3287
|
<View key={entry.id} style={{ padding: t.space(5), gap: t.space(4), flex: 1 }}>
|
|
2959
3288
|
{saveHandler && error ? <FormAlert message={error} onDismiss={clearError} /> : null}
|
|
2960
3289
|
<View style={{ gap: t.space(2) }}>
|
|
@@ -2968,6 +3297,7 @@ function EntryDetail({
|
|
|
2968
3297
|
readOnly={readOnly}
|
|
2969
3298
|
/>
|
|
2970
3299
|
</View>
|
|
3300
|
+
</DocumentPathProvider>
|
|
2971
3301
|
)}
|
|
2972
3302
|
|
|
2973
3303
|
{prompt === "rename" && onRename ? (
|
|
@@ -3029,27 +3359,6 @@ function useMultiSelect(resetKey: string) {
|
|
|
3029
3359
|
return { checked, toggle, clear, checkedIds, checkedFolders, toggleFolder, checkedFolderKeys };
|
|
3030
3360
|
}
|
|
3031
3361
|
|
|
3032
|
-
// CheckBox is the row-selection control for multi-select.
|
|
3033
|
-
function CheckBox({ value, onToggle }: { value: boolean; onToggle: () => void }) {
|
|
3034
|
-
const t = useTheme();
|
|
3035
|
-
return (
|
|
3036
|
-
<Pressable
|
|
3037
|
-
// Stop the press from bubbling to the row's open handler (the checkbox
|
|
3038
|
-
// sits inside the row Pressable) so toggling never also opens the entry.
|
|
3039
|
-
onPress={(e?: { stopPropagation?: () => void }) => { e?.stopPropagation?.(); onToggle(); }}
|
|
3040
|
-
testID="entry-checkbox"
|
|
3041
|
-
style={{
|
|
3042
|
-
width: 18, height: 18, borderRadius: t.radius.sm, borderWidth: 1,
|
|
3043
|
-
borderColor: value ? t.color.borderStrong : t.color.borderDefault,
|
|
3044
|
-
backgroundColor: value ? t.color.surfaceInverted : t.color.surfaceRaised,
|
|
3045
|
-
alignItems: "center", justifyContent: "center",
|
|
3046
|
-
}}
|
|
3047
|
-
>
|
|
3048
|
-
{value ? <Icon name="check" size={12} color={t.color.textInverted} /> : null}
|
|
3049
|
-
</Pressable>
|
|
3050
|
-
);
|
|
3051
|
-
}
|
|
3052
|
-
|
|
3053
3362
|
// SelectionBar appears above the entry list when rows are checked; it offers a
|
|
3054
3363
|
// bulk move and/or delete plus a clear action (each shown only when wired).
|
|
3055
3364
|
function SelectionBar({ count, onMove, onDelete, onClear }: { count: number; onMove?: () => void; onDelete?: () => void; onClear: () => void }) {
|
|
@@ -3192,7 +3501,7 @@ function EntriesList({
|
|
|
3192
3501
|
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
|
3193
3502
|
{showChecks ? (
|
|
3194
3503
|
<View style={{ paddingLeft: t.space(4) }}>
|
|
3195
|
-
<CheckBox value={!!checked?.[e.id]} onToggle={() => onToggleEntry?.(e.id)} />
|
|
3504
|
+
<CheckBox value={!!checked?.[e.id]} onToggle={() => onToggleEntry?.(e.id)} testID="entry-checkbox" />
|
|
3196
3505
|
</View>
|
|
3197
3506
|
) : null}
|
|
3198
3507
|
<View style={{ flex: 1 }}>
|
|
@@ -3567,9 +3876,28 @@ function ReadOnlyBanner({ notice, actionLabel, onAction }: { notice?: string; ac
|
|
|
3567
3876
|
);
|
|
3568
3877
|
}
|
|
3569
3878
|
|
|
3570
|
-
//
|
|
3571
|
-
//
|
|
3572
|
-
//
|
|
3879
|
+
// ModalOverlays renders the browser's full-screen modals — apply changes /
|
|
3880
|
+
// change request, and the protected-branch save prompt. Shared by both layouts
|
|
3881
|
+
// so every modal is reachable on mobile too.
|
|
3882
|
+
function ModalOverlays(props: ContentBrowserProps) {
|
|
3883
|
+
return (
|
|
3884
|
+
<>
|
|
3885
|
+
<ApplyModalOverlay {...props} />
|
|
3886
|
+
{props.protectedBranch ? (
|
|
3887
|
+
<ProtectedBranchModal
|
|
3888
|
+
branch={props.protectedBranch}
|
|
3889
|
+
documentLabel={props.protectedBranchDocument}
|
|
3890
|
+
suggestedName={props.protectedBranchSuggestedName}
|
|
3891
|
+
busy={props.creatingProtectedBranch}
|
|
3892
|
+
error={props.protectedBranchError}
|
|
3893
|
+
onCreate={props.onCreateBranchAndSave ?? (() => {})}
|
|
3894
|
+
onCancel={props.onCancelProtectedSave ?? (() => {})}
|
|
3895
|
+
/>
|
|
3896
|
+
) : null}
|
|
3897
|
+
</>
|
|
3898
|
+
);
|
|
3899
|
+
}
|
|
3900
|
+
|
|
3573
3901
|
function ApplyModalOverlay(props: ContentBrowserProps) {
|
|
3574
3902
|
if (!props.applyOpen) return null;
|
|
3575
3903
|
return (
|
|
@@ -3577,6 +3905,7 @@ function ApplyModalOverlay(props: ContentBrowserProps) {
|
|
|
3577
3905
|
sourceBranch={props.workspace.branch}
|
|
3578
3906
|
targetBranch={props.targetBranch ?? props.defaultBranch ?? ""}
|
|
3579
3907
|
summary={props.applySummary}
|
|
3908
|
+
reviewReason={props.applyReviewReason}
|
|
3580
3909
|
progress={props.applyProgress}
|
|
3581
3910
|
conflictCount={props.applyConflictCount}
|
|
3582
3911
|
changeRequestOpen={props.changeRequestOpen}
|
|
@@ -3937,7 +4266,9 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
3937
4266
|
<EntryDetail
|
|
3938
4267
|
entry={col.entry}
|
|
3939
4268
|
documentActions={props.documentActions}
|
|
4269
|
+
history={props.history}
|
|
3940
4270
|
onEntryDraft={props.onEntryDraft}
|
|
4271
|
+
onFieldFocus={props.onFieldFocus}
|
|
3941
4272
|
renderField={renderField}
|
|
3942
4273
|
onSaveEntry={props.onSaveEntry}
|
|
3943
4274
|
readOnly={col.readOnly}
|
|
@@ -4066,7 +4397,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4066
4397
|
const readOnlyBanner = (
|
|
4067
4398
|
<ReadOnlyBanner notice={props.readOnlyNotice} actionLabel={props.readOnlyNoticeActionLabel} onAction={props.onReadOnlyNoticeAction} />
|
|
4068
4399
|
);
|
|
4069
|
-
const
|
|
4400
|
+
const modalOverlays = <ModalOverlays {...props} />;
|
|
4070
4401
|
|
|
4071
4402
|
// The changes surface reuses the same three-pane model as Edit, so switching
|
|
4072
4403
|
// between them doesn't relayout the screen — only what each pane contains
|
|
@@ -4119,7 +4450,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4119
4450
|
conflicts={props.selectedConflicts}
|
|
4120
4451
|
onResolveConflict={props.onResolveConflict}
|
|
4121
4452
|
/>
|
|
4122
|
-
{
|
|
4453
|
+
{modalOverlays}
|
|
4123
4454
|
</AppShell>
|
|
4124
4455
|
);
|
|
4125
4456
|
}
|
|
@@ -4134,7 +4465,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4134
4465
|
<Pane flex={1} testID="pane-plugin" scroll={false}>
|
|
4135
4466
|
{props.contentSlot}
|
|
4136
4467
|
</Pane>
|
|
4137
|
-
{
|
|
4468
|
+
{modalOverlays}
|
|
4138
4469
|
</AppShell>
|
|
4139
4470
|
);
|
|
4140
4471
|
}
|
|
@@ -4143,11 +4474,41 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4143
4474
|
// lists the media sets exactly where a collection's documents would be, and the
|
|
4144
4475
|
// details pane holds the browser for whichever set is selected. Selecting a set
|
|
4145
4476
|
// goes through onSelectNav, so it lands in the URL like any other selection.
|
|
4146
|
-
// Forms
|
|
4147
|
-
//
|
|
4148
|
-
//
|
|
4149
|
-
//
|
|
4477
|
+
// Forms enter through the same content column every other surface uses: the
|
|
4478
|
+
// list of forms sits exactly where a collection's documents would, at the
|
|
4479
|
+
// list width, with the empty state beside it. A list of half a dozen rows
|
|
4480
|
+
// stretched across everything after the sidebar reads as a different kind of
|
|
4481
|
+
// screen than Edit, Changes and Media, when it is the same kind of screen.
|
|
4482
|
+
//
|
|
4483
|
+
// Opening a form is where forms stop fitting the three-pane model, and so it
|
|
4484
|
+
// is where they leave it: that surface is two more levels deep (submissions →
|
|
4485
|
+
// one submission), and threading those through panes meant for collection →
|
|
4486
|
+
// document → editor would give a pane whose meaning changes with the level.
|
|
4487
|
+
// FormsBrowser owns its own split from there.
|
|
4150
4488
|
if (formsShowing && props.forms) {
|
|
4489
|
+
if (!activeForm) {
|
|
4490
|
+
return (
|
|
4491
|
+
<AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
|
|
4492
|
+
{navPane}
|
|
4493
|
+
<ResizeHandle width={navW} min={180} max={420} onChange={setNavW} testID="resize-nav" />
|
|
4494
|
+
|
|
4495
|
+
<Pane width={listW} testID="pane-forms" scroll={false} header={<PaneTitle title="Forms" />}>
|
|
4496
|
+
<FormsBrowser
|
|
4497
|
+
api={props.forms}
|
|
4498
|
+
form={null}
|
|
4499
|
+
onSelectForm={(name) => onSelectNav(name ? formsNavKey(name) : FORMS_NAV_KEY)}
|
|
4500
|
+
variant="desktop"
|
|
4501
|
+
/>
|
|
4502
|
+
</Pane>
|
|
4503
|
+
<ResizeHandle width={listW} min={260} max={640} onChange={setListW} testID="resize-forms" />
|
|
4504
|
+
|
|
4505
|
+
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
|
|
4506
|
+
<Text variant="body" color="tertiary" testID="forms-empty">Select a form</Text>
|
|
4507
|
+
</View>
|
|
4508
|
+
{modalOverlays}
|
|
4509
|
+
</AppShell>
|
|
4510
|
+
);
|
|
4511
|
+
}
|
|
4151
4512
|
return (
|
|
4152
4513
|
<AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
|
|
4153
4514
|
{navPane}
|
|
@@ -4162,7 +4523,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4162
4523
|
variant="desktop"
|
|
4163
4524
|
/>
|
|
4164
4525
|
</Pane>
|
|
4165
|
-
{
|
|
4526
|
+
{modalOverlays}
|
|
4166
4527
|
</AppShell>
|
|
4167
4528
|
);
|
|
4168
4529
|
}
|
|
@@ -4189,7 +4550,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4189
4550
|
<Text variant="body" color="tertiary" testID="media-sets-empty">Select a media collection</Text>
|
|
4190
4551
|
</View>
|
|
4191
4552
|
)}
|
|
4192
|
-
{
|
|
4553
|
+
{modalOverlays}
|
|
4193
4554
|
</AppShell>
|
|
4194
4555
|
);
|
|
4195
4556
|
}
|
|
@@ -4390,7 +4751,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4390
4751
|
onClose={() => setMovePrompt(false)}
|
|
4391
4752
|
/>
|
|
4392
4753
|
) : null}
|
|
4393
|
-
{
|
|
4754
|
+
{modalOverlays}
|
|
4394
4755
|
</AppShell>
|
|
4395
4756
|
);
|
|
4396
4757
|
}
|
|
@@ -4466,7 +4827,7 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4466
4827
|
const readOnlyBanner = (
|
|
4467
4828
|
<ReadOnlyBanner notice={props.readOnlyNotice} actionLabel={props.readOnlyNoticeActionLabel} onAction={props.onReadOnlyNoticeAction} />
|
|
4468
4829
|
);
|
|
4469
|
-
const
|
|
4830
|
+
const modalOverlays = <ModalOverlays {...props} />;
|
|
4470
4831
|
|
|
4471
4832
|
// Media drills the same way a collection does — nav → list → detail — so the
|
|
4472
4833
|
// back arrow means the same thing at every level. Pressing Media lists the
|
|
@@ -4592,7 +4953,7 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4592
4953
|
testID="mobile-shell"
|
|
4593
4954
|
header={header}
|
|
4594
4955
|
title={props.surface === "changes" ? "Changes" : "Content"}
|
|
4595
|
-
overlay={
|
|
4956
|
+
overlay={modalOverlays}
|
|
4596
4957
|
>
|
|
4597
4958
|
{/* Edit / Changes surface toggle (fits its content) with the Apply button
|
|
4598
4959
|
to its right on the changes surface. The read-only notice sits below. */}
|
|
@@ -4673,7 +5034,7 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4673
5034
|
testID="mobile-entries"
|
|
4674
5035
|
scroll={false}
|
|
4675
5036
|
banner={readOnlyBanner}
|
|
4676
|
-
overlay={
|
|
5037
|
+
overlay={modalOverlays}
|
|
4677
5038
|
header={
|
|
4678
5039
|
search.open ? (
|
|
4679
5040
|
<SearchHeaderBar search={search} showFilter={facetFields.length > 0} />
|
|
@@ -4754,7 +5115,7 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4754
5115
|
<MobileScreen
|
|
4755
5116
|
testID="mobile-entry"
|
|
4756
5117
|
banner={readOnlyBanner}
|
|
4757
|
-
overlay={
|
|
5118
|
+
overlay={modalOverlays}
|
|
4758
5119
|
header={
|
|
4759
5120
|
<>
|
|
4760
5121
|
<IconButton name="chevronLeft" onPress={backToEntries} size="md" label="Back" />
|
|
@@ -4782,7 +5143,9 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4782
5143
|
<EntryDetail
|
|
4783
5144
|
entry={entry}
|
|
4784
5145
|
documentActions={props.documentActions}
|
|
5146
|
+
history={props.history}
|
|
4785
5147
|
onEntryDraft={props.onEntryDraft}
|
|
5148
|
+
onFieldFocus={props.onFieldFocus}
|
|
4786
5149
|
renderField={renderField}
|
|
4787
5150
|
onSaveEntry={props.onSaveEntry}
|
|
4788
5151
|
readOnly={readOnly}
|
|
@@ -5052,14 +5415,20 @@ function mediaSetLabel(sets: MediaSetInfo[], name: string): string {
|
|
|
5052
5415
|
return set ? titleCaseWord(set.name) : titleCaseWord(name);
|
|
5053
5416
|
}
|
|
5054
5417
|
|
|
5055
|
-
// PaneTitle is the content pane's heading
|
|
5056
|
-
//
|
|
5418
|
+
// PaneTitle is the content pane's heading for the surfaces with no controls
|
|
5419
|
+
// beside it — Changes, Media, Forms.
|
|
5420
|
+
//
|
|
5421
|
+
// Deliberately the same Text the collection header uses (`listHeader`), and
|
|
5422
|
+
// nothing else. Pane's header row already supplies the height, the horizontal
|
|
5423
|
+
// padding and the rule beneath, so the padded wrapper this used to add sat
|
|
5424
|
+
// inside that padding and pushed the title a step right of every collection's —
|
|
5425
|
+
// at `body` rather than `h3`, so it read a size smaller too. Switching between
|
|
5426
|
+
// Posts and Media moved the heading twice over.
|
|
5057
5427
|
function PaneTitle({ title }: { title: string }) {
|
|
5058
|
-
const t = useTheme();
|
|
5059
5428
|
return (
|
|
5060
|
-
<
|
|
5061
|
-
|
|
5062
|
-
</
|
|
5429
|
+
<Text variant="h3" weight="semibold" style={{ flex: 1 }}>
|
|
5430
|
+
{title}
|
|
5431
|
+
</Text>
|
|
5063
5432
|
);
|
|
5064
5433
|
}
|
|
5065
5434
|
|