@gogitcms/design-system 0.16.0-next.1 → 0.16.0-next.11
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 +47 -1
- package/src/__tests__/ContentBrowser.history.test.tsx +385 -0
- package/src/__tests__/ContentBrowser.historycollab.test.tsx +433 -0
- package/src/__tests__/ContentBrowser.references.test.tsx +179 -0
- package/src/__tests__/ContentBrowser.slotmedia.test.tsx +150 -0
- 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 +494 -77
- package/src/components/DocumentDetails.tsx +0 -0
- package/src/components/DocumentHistory.tsx +408 -0
- 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/ReferenceField.tsx +0 -0
- package/src/components/primitives.tsx +46 -1
- package/src/fieldComponents.ts +2 -2
- package/src/history.ts +82 -0
- package/src/icons.ts +1 -0
- package/src/index.ts +28 -3
- package/src/media.ts +15 -0
- package/src/references.ts +97 -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,21 @@ 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";
|
|
35
|
+
import { ReferenceField, ReferenceProvider } from "./ReferenceField";
|
|
36
|
+
import { DocumentDetails } from "./DocumentDetails";
|
|
37
|
+
import type { ReferenceApi, ReferenceDef } from "../references";
|
|
33
38
|
import { MediaBrowser } from "./MediaBrowser";
|
|
34
39
|
import { FormsBrowser } from "./FormsBrowser";
|
|
35
40
|
import { FORMS_NAV_KEY, formsNavKey, isFormsNavKey, parseFormsNavKey, type FormsApi } from "../forms";
|
|
41
|
+
import { type HistoryApi } from "../history";
|
|
42
|
+
import { DocumentHistory } from "./DocumentHistory";
|
|
36
43
|
import {
|
|
37
44
|
MEDIA_NAV_KEY,
|
|
38
45
|
mediaNavKey,
|
|
39
46
|
isMediaNavKey,
|
|
40
47
|
parseMediaNavKey,
|
|
48
|
+
type FieldMediaApi,
|
|
41
49
|
type MediaApi,
|
|
42
50
|
type MediaSetInfo,
|
|
43
51
|
type StoreAs,
|
|
@@ -71,6 +79,12 @@ export type EntryField = {
|
|
|
71
79
|
// field these describe its *items*, making each one a media picker.
|
|
72
80
|
media?: string;
|
|
73
81
|
storeAs?: string;
|
|
82
|
+
// For a document picker (`component: "reference"`, or an array whose items
|
|
83
|
+
// are references): what the field points at and stores. An object-shaped
|
|
84
|
+
// reference's `fields` are the derived children — the key child and the
|
|
85
|
+
// embedded copies — which the server recomputes on every save and the
|
|
86
|
+
// editor shows read-only (docs/references.md).
|
|
87
|
+
reference?: ReferenceDef;
|
|
74
88
|
value: unknown;
|
|
75
89
|
// Item type for an array field (string | number | boolean | object).
|
|
76
90
|
of?: string;
|
|
@@ -111,6 +125,16 @@ export type FieldSlotArgs = {
|
|
|
111
125
|
// CRDT and publish presence.
|
|
112
126
|
collab?: CollabApi;
|
|
113
127
|
path?: string;
|
|
128
|
+
// Bumped when the host replaces this field's value outright rather than the
|
|
129
|
+
// user editing it — restoring it from a past version. A controlled control
|
|
130
|
+
// needs nothing from this (it already re-renders with the new value), but the
|
|
131
|
+
// body editor does: in a live session it ignores `value` and mirrors the
|
|
132
|
+
// shared fragment, so "the value changed" is not a thing it can observe.
|
|
133
|
+
resetNonce?: number;
|
|
134
|
+
// Media access scoped to this field — open the picker, resolve the paths the
|
|
135
|
+
// document holds — so a host control can embed and display images from the
|
|
136
|
+
// media library. Absent when the host provides no media api.
|
|
137
|
+
media?: FieldMediaApi;
|
|
114
138
|
};
|
|
115
139
|
export type RenderField = (args: FieldSlotArgs) => React.ReactNode;
|
|
116
140
|
|
|
@@ -326,6 +350,15 @@ export type ContentBrowserProps = {
|
|
|
326
350
|
// already separated from the frontmatter.
|
|
327
351
|
onEntryDraft?: (draft: EntrySaveChange, entry: CmsEntry) => void;
|
|
328
352
|
|
|
353
|
+
// Which field of an open document has keyboard focus, as the author moves
|
|
354
|
+
// between controls: the field's dotted path (`title`, `seo.description`,
|
|
355
|
+
// `blocks.0.heading`), or null when nothing in that document is focused.
|
|
356
|
+
// Web only — it reads the DOM's focus events. This is what lets a preview
|
|
357
|
+
// ring the part of the page the author is editing (§4.4 of the preview
|
|
358
|
+
// design), and it is separate from live collaboration: a lone author gets
|
|
359
|
+
// it too.
|
|
360
|
+
onFieldFocus?: (entryId: string, path: string | null) => void;
|
|
361
|
+
|
|
329
362
|
// Optional per-field renderer. When it returns a node for a field, that node
|
|
330
363
|
// replaces the built-in control (used to inject the markdown body editor).
|
|
331
364
|
renderField?: RenderField;
|
|
@@ -336,9 +369,22 @@ export type ContentBrowserProps = {
|
|
|
336
369
|
// store gets.
|
|
337
370
|
media?: MediaApi;
|
|
338
371
|
|
|
372
|
+
// References data seam, the same split (docs/references.md §6): the DS owns
|
|
373
|
+
// the document picker and the "Referenced by" list, the host owns fetching.
|
|
374
|
+
// Omitted → reference fields render read-only.
|
|
375
|
+
references?: ReferenceApi;
|
|
376
|
+
|
|
339
377
|
// Forms data seam, the same split (docs/forms.md §10.2). Omitted → the Forms
|
|
340
378
|
// surface never renders, which is what a config declaring no forms looks like.
|
|
341
379
|
forms?: FormsApi;
|
|
380
|
+
|
|
381
|
+
// Document-history data seam, the same split again: the DS owns the version
|
|
382
|
+
// browser and its ephemeral state, the host owns fetching. Provided → an open
|
|
383
|
+
// document's header gains a history button, and pressing it gives that
|
|
384
|
+
// column's body over to the version browser. Omitted → no affordance at all,
|
|
385
|
+
// which is what a deployment with no provider (local mode, desktop) gets: a
|
|
386
|
+
// feature the user never sees beats one that errors when clicked.
|
|
387
|
+
history?: HistoryApi;
|
|
342
388
|
// The selected submission's id, and the reporter for taps — bound to the URL
|
|
343
389
|
// by the host exactly as document selection is.
|
|
344
390
|
selectedSubmissionId?: string | null;
|
|
@@ -465,6 +511,9 @@ export type ContentBrowserProps = {
|
|
|
465
511
|
canApplyChanges?: boolean;
|
|
466
512
|
applyOpen?: boolean;
|
|
467
513
|
applySummary?: string;
|
|
514
|
+
// Why applying opens a change request instead of merging (see
|
|
515
|
+
// ApplyChangesModal.reviewReason). Absent for a direct merge.
|
|
516
|
+
applyReviewReason?: string;
|
|
468
517
|
applyProgress?: MergeProgress;
|
|
469
518
|
applyConflictCount?: number;
|
|
470
519
|
onConfirmApply?: () => void;
|
|
@@ -478,6 +527,19 @@ export type ContentBrowserProps = {
|
|
|
478
527
|
changeRequestOpen?: boolean;
|
|
479
528
|
changeRequestUrl?: string;
|
|
480
529
|
creatingChangeRequest?: boolean;
|
|
530
|
+
|
|
531
|
+
// Protected-branch save prompt. The server refuses a save to a branch the
|
|
532
|
+
// provider protects; the host catches that, sets protectedBranch to the branch
|
|
533
|
+
// name, and this modal offers the one thing that resolves it — a branch to put
|
|
534
|
+
// the edit on. onCreateBranchAndSave receives the name; the host creates the
|
|
535
|
+
// branch and applies the pending edit to it in one server call.
|
|
536
|
+
protectedBranch?: string;
|
|
537
|
+
protectedBranchDocument?: string;
|
|
538
|
+
protectedBranchSuggestedName?: string;
|
|
539
|
+
creatingProtectedBranch?: boolean;
|
|
540
|
+
protectedBranchError?: string | null;
|
|
541
|
+
onCreateBranchAndSave?: (name: string) => void;
|
|
542
|
+
onCancelProtectedSave?: () => void;
|
|
481
543
|
onCreateChangeRequest?: (explanation: string) => void;
|
|
482
544
|
onViewChangeRequest?: () => void;
|
|
483
545
|
|
|
@@ -743,8 +805,45 @@ type FieldControlProps = {
|
|
|
743
805
|
// When this number changes, the field programmatically focuses itself (used to
|
|
744
806
|
// jump to a peer's field from its presence avatar).
|
|
745
807
|
focusSignal?: number;
|
|
808
|
+
// Bumped when this field's value was replaced by the host rather than typed —
|
|
809
|
+
// restored from a past version. Only the host-supplied body editor needs it
|
|
810
|
+
// (see FieldSlotArgs.resetNonce); every built-in control is controlled and
|
|
811
|
+
// re-renders from `value` on its own.
|
|
812
|
+
resetNonce?: number;
|
|
813
|
+
// The field's full dotted path, when the caller knows better than
|
|
814
|
+
// "parent path + field name" — a list item, whose control is named after
|
|
815
|
+
// the list but sits at `list.<index>`.
|
|
816
|
+
fieldPath?: string;
|
|
746
817
|
};
|
|
747
818
|
|
|
819
|
+
// The dotted path of the field being rendered, for the controls under it:
|
|
820
|
+
// a group's children append their names to it, a list's items append their
|
|
821
|
+
// index. It exists so every control can carry its own path as a DOM
|
|
822
|
+
// attribute (`data-cms-field`) without any of them threading a prop through
|
|
823
|
+
// — the focus reporting in EntryDetail reads that attribute off whichever
|
|
824
|
+
// element the keyboard lands in.
|
|
825
|
+
//
|
|
826
|
+
// Deliberately separate from the collab `path` prop: that one is threaded
|
|
827
|
+
// only where a CRDT binding exists (top-level fields and drilled-in groups),
|
|
828
|
+
// whereas this is present for every field including list items, which the
|
|
829
|
+
// collab layer treats as one register.
|
|
830
|
+
const FieldPathContext = React.createContext<string>("");
|
|
831
|
+
|
|
832
|
+
function FieldControl(props: FieldControlProps) {
|
|
833
|
+
const prefix = React.useContext(FieldPathContext);
|
|
834
|
+
const fullPath = props.fieldPath ?? (prefix ? `${prefix}.${props.field.name}` : props.field.name);
|
|
835
|
+
return (
|
|
836
|
+
<FieldPathContext.Provider value={fullPath}>
|
|
837
|
+
<View
|
|
838
|
+
// @ts-expect-error react-native-web maps dataSet -> data-* attributes
|
|
839
|
+
dataSet={{ cmsField: fullPath }}
|
|
840
|
+
>
|
|
841
|
+
<FieldControlInner {...props} />
|
|
842
|
+
</View>
|
|
843
|
+
</FieldPathContext.Provider>
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
|
|
748
847
|
// The scalar type an array's `of` maps to when rendering item controls.
|
|
749
848
|
function ofToType(of?: string): string {
|
|
750
849
|
switch (of) {
|
|
@@ -755,8 +854,8 @@ function ofToType(of?: string): string {
|
|
|
755
854
|
}
|
|
756
855
|
}
|
|
757
856
|
|
|
758
|
-
function
|
|
759
|
-
const { field, value, onChange, renderField, readOnly = false, error, hideLabel, onOpenGroup, collab, path, focusSignal } = props;
|
|
857
|
+
function FieldControlInner(props: FieldControlProps) {
|
|
858
|
+
const { field, value, onChange, renderField, readOnly = false, error, hideLabel, onOpenGroup, collab, path, focusSignal, resetNonce } = props;
|
|
760
859
|
const t = useTheme();
|
|
761
860
|
const label = hideLabel ? "" : field.label || field.name;
|
|
762
861
|
// A text field participates in live collaboration when the document has a
|
|
@@ -767,9 +866,14 @@ function FieldControl(props: FieldControlProps) {
|
|
|
767
866
|
) : null;
|
|
768
867
|
const labelNode = label ? <Text variant="label" color="tertiary">{label}</Text> : null;
|
|
769
868
|
|
|
869
|
+
// Media for a host-supplied control. The picker element is mounted only when
|
|
870
|
+
// the slot actually renders this field, so a form full of built-in controls
|
|
871
|
+
// carries no extra modals.
|
|
872
|
+
const fieldMedia = useFieldMedia({ set: field.media, storeAs: field.storeAs });
|
|
873
|
+
|
|
770
874
|
// Host-supplied control (e.g. the markdown editor) takes precedence.
|
|
771
875
|
if (renderField) {
|
|
772
|
-
const custom = renderField({ field, value, onChange, readOnly, collab: collabText, path });
|
|
876
|
+
const custom = renderField({ field, value, onChange, readOnly, collab: collabText, path, resetNonce, media: fieldMedia.media });
|
|
773
877
|
if (custom != null && custom !== false) {
|
|
774
878
|
return (
|
|
775
879
|
<View style={{ gap: t.space(2) }}>
|
|
@@ -780,6 +884,7 @@ function FieldControl(props: FieldControlProps) {
|
|
|
780
884
|
custom
|
|
781
885
|
)}
|
|
782
886
|
{errorNode}
|
|
887
|
+
{fieldMedia.picker}
|
|
783
888
|
</View>
|
|
784
889
|
);
|
|
785
890
|
}
|
|
@@ -787,6 +892,28 @@ function FieldControl(props: FieldControlProps) {
|
|
|
787
892
|
|
|
788
893
|
const comp = resolveComponent(field);
|
|
789
894
|
|
|
895
|
+
// ---- references ----------------------------------------------------------
|
|
896
|
+
// Like media, one component for two field types: a string stores the key, an
|
|
897
|
+
// object stores the key plus the server-computed copies. Checked before the
|
|
898
|
+
// type branches for the same reason media is.
|
|
899
|
+
if (comp === "reference" && field.reference) {
|
|
900
|
+
return (
|
|
901
|
+
<View style={{ gap: t.space(2) }}>
|
|
902
|
+
{labelNode}
|
|
903
|
+
<ReferenceField
|
|
904
|
+
value={value}
|
|
905
|
+
onChange={onChange}
|
|
906
|
+
shape={field.type === "object" ? "object" : "string"}
|
|
907
|
+
def={field.reference}
|
|
908
|
+
embedFields={field.fields}
|
|
909
|
+
readOnly={readOnly}
|
|
910
|
+
error={!!error}
|
|
911
|
+
/>
|
|
912
|
+
{errorNode}
|
|
913
|
+
</View>
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
|
|
790
917
|
// ---- media ------------------------------------------------------------
|
|
791
918
|
// Checked before the type-specific branches because `media` is the one
|
|
792
919
|
// component valid for two different field types: a string field stores a path,
|
|
@@ -1590,13 +1717,19 @@ function ListControl({
|
|
|
1590
1717
|
fields: field.fields,
|
|
1591
1718
|
value: null,
|
|
1592
1719
|
...(field.media ? { component: "media", media: field.media, storeAs: field.storeAs } : {}),
|
|
1720
|
+
// An array that declares a reference makes each item a document picker, the
|
|
1721
|
+
// same way an array naming a media set makes each item a media picker.
|
|
1722
|
+
...(field.reference ? { component: "reference", reference: field.reference } : {}),
|
|
1593
1723
|
};
|
|
1594
1724
|
|
|
1725
|
+
// The list's own path, from the FieldControl wrapping this control; an item
|
|
1726
|
+
// sits at `<list>.<index>`.
|
|
1727
|
+
const listPath = React.useContext(FieldPathContext);
|
|
1595
1728
|
return (
|
|
1596
1729
|
<View style={{ gap: t.space(2) }}>
|
|
1597
1730
|
{items.map((it, i) => (
|
|
1598
1731
|
<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 />
|
|
1732
|
+
<FieldControl field={itemField} value={it} onChange={(v) => setItem(i, v)} renderField={renderField} readOnly={readOnly} hideLabel fieldPath={listPath ? `${listPath}.${i}` : String(i)} />
|
|
1600
1733
|
</ItemFrame>
|
|
1601
1734
|
))}
|
|
1602
1735
|
{!readOnly && canAdd ? (
|
|
@@ -1636,15 +1769,25 @@ function MixedListControl({
|
|
|
1636
1769
|
onChange(next);
|
|
1637
1770
|
};
|
|
1638
1771
|
const addItem = () => onChange([...items, { [key]: variants[0]?.name ?? "" }]);
|
|
1772
|
+
const listPath = React.useContext(FieldPathContext);
|
|
1639
1773
|
|
|
1640
1774
|
return (
|
|
1641
1775
|
<View style={{ gap: t.space(2) }}>
|
|
1642
1776
|
{items.map((it, i) => {
|
|
1643
1777
|
const variantName = String(it?.[key] ?? "");
|
|
1644
1778
|
const variant = variants.find((v) => v.name === variantName);
|
|
1779
|
+
const itemPath = listPath ? `${listPath}.${i}` : String(i);
|
|
1645
1780
|
return (
|
|
1646
1781
|
<ItemFrame key={i} index={i} count={items.length} readOnly={readOnly || !canRemove} onRemove={() => removeItem(i)} onMove={(d) => moveItem(i, d)}>
|
|
1647
|
-
|
|
1782
|
+
{/* The item's fields compose under `<list>.<index>`; the item
|
|
1783
|
+
itself carries that path so focusing its variant picker
|
|
1784
|
+
reports the item rather than the whole list. */}
|
|
1785
|
+
<FieldPathContext.Provider value={itemPath}>
|
|
1786
|
+
<View
|
|
1787
|
+
style={{ gap: t.space(2) }}
|
|
1788
|
+
// @ts-expect-error react-native-web maps dataSet -> data-* attributes
|
|
1789
|
+
dataSet={{ cmsField: itemPath }}
|
|
1790
|
+
>
|
|
1648
1791
|
<SelectControl
|
|
1649
1792
|
value={variantName}
|
|
1650
1793
|
options={variants.map((v) => v.name)}
|
|
@@ -1662,6 +1805,7 @@ function MixedListControl({
|
|
|
1662
1805
|
/>
|
|
1663
1806
|
) : null}
|
|
1664
1807
|
</View>
|
|
1808
|
+
</FieldPathContext.Provider>
|
|
1665
1809
|
</ItemFrame>
|
|
1666
1810
|
);
|
|
1667
1811
|
})}
|
|
@@ -1697,6 +1841,9 @@ function BodyField({
|
|
|
1697
1841
|
format: "markdown",
|
|
1698
1842
|
value,
|
|
1699
1843
|
};
|
|
1844
|
+
// The schema-less body names no media set; the picker opens on the first
|
|
1845
|
+
// configured one and writes public paths.
|
|
1846
|
+
const fieldMedia = useFieldMedia({});
|
|
1700
1847
|
// The slot's onChange is unknown-typed (plugin fields may emit any shape);
|
|
1701
1848
|
// a body control only ever emits markdown strings.
|
|
1702
1849
|
const custom = renderField?.({
|
|
@@ -1704,15 +1851,25 @@ function BodyField({
|
|
|
1704
1851
|
value,
|
|
1705
1852
|
onChange: (v) => onChange(typeof v === "string" ? v : String(v ?? "")),
|
|
1706
1853
|
readOnly,
|
|
1854
|
+
media: fieldMedia.media,
|
|
1707
1855
|
});
|
|
1708
1856
|
return (
|
|
1709
|
-
<View
|
|
1857
|
+
<View
|
|
1858
|
+
style={{ gap: t.space(2), flex: 1 }}
|
|
1859
|
+
// The schema-less body is the document's one field; a preview rings
|
|
1860
|
+
// it under the same name the schema form would give it.
|
|
1861
|
+
// @ts-expect-error react-native-web maps dataSet -> data-* attributes
|
|
1862
|
+
dataSet={{ cmsField: "body" }}
|
|
1863
|
+
>
|
|
1710
1864
|
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
|
1711
1865
|
<Text variant="label" color="tertiary">Body</Text>
|
|
1712
1866
|
<Text variant="monoSm" color="tertiary">Markdown</Text>
|
|
1713
1867
|
</View>
|
|
1714
1868
|
{custom != null && custom !== false ? (
|
|
1715
|
-
|
|
1869
|
+
<>
|
|
1870
|
+
{custom}
|
|
1871
|
+
{fieldMedia.picker}
|
|
1872
|
+
</>
|
|
1716
1873
|
) : (
|
|
1717
1874
|
<View
|
|
1718
1875
|
style={{
|
|
@@ -2385,14 +2542,18 @@ function PromptDialog({
|
|
|
2385
2542
|
);
|
|
2386
2543
|
}
|
|
2387
2544
|
|
|
2388
|
-
// DetailMenu is the "..." menu in the detail header: Rename / Move to
|
|
2389
|
-
// Delete, each shown only when its handler is provided.
|
|
2390
|
-
//
|
|
2545
|
+
// DetailMenu is the "..." menu in the detail header: Details / Rename / Move to
|
|
2546
|
+
// folder / Delete, each shown only when its handler is provided. Details is a
|
|
2547
|
+
// read (metadata and references, see DocumentDetails) and so is offered on
|
|
2548
|
+
// read-only documents too; Delete has an inline confirm step; Rename/Move open
|
|
2549
|
+
// a PromptDialog owned by EntryDetail.
|
|
2391
2550
|
function DetailMenu({
|
|
2551
|
+
onDetails,
|
|
2392
2552
|
onRename,
|
|
2393
2553
|
onMove,
|
|
2394
2554
|
onDelete,
|
|
2395
2555
|
}: {
|
|
2556
|
+
onDetails?: () => void;
|
|
2396
2557
|
onRename?: () => void;
|
|
2397
2558
|
onMove?: () => void;
|
|
2398
2559
|
onDelete?: () => void;
|
|
@@ -2423,6 +2584,12 @@ function DetailMenu({
|
|
|
2423
2584
|
borderRadius: t.radius.md, paddingVertical: t.space(1),
|
|
2424
2585
|
}}
|
|
2425
2586
|
>
|
|
2587
|
+
{onDetails ? (
|
|
2588
|
+
<Pressable testID="detail-details" onPress={() => { close(); onDetails(); }} style={rowStyle}>
|
|
2589
|
+
<Icon name="fileText" size={15} color={t.color.textSecondary} />
|
|
2590
|
+
<Text variant="sm">Details</Text>
|
|
2591
|
+
</Pressable>
|
|
2592
|
+
) : null}
|
|
2426
2593
|
{onRename ? (
|
|
2427
2594
|
<Pressable testID="detail-rename" onPress={() => { close(); onRename(); }} style={rowStyle}>
|
|
2428
2595
|
<Icon name="file" size={15} color={t.color.textSecondary} />
|
|
@@ -2558,6 +2725,7 @@ function EntryDetail({
|
|
|
2558
2725
|
entry,
|
|
2559
2726
|
documentActions,
|
|
2560
2727
|
onEntryDraft,
|
|
2728
|
+
onFieldFocus,
|
|
2561
2729
|
renderField,
|
|
2562
2730
|
onSaveEntry,
|
|
2563
2731
|
readOnly = false,
|
|
@@ -2565,8 +2733,10 @@ function EntryDetail({
|
|
|
2565
2733
|
onDeleteEntry,
|
|
2566
2734
|
onRename,
|
|
2567
2735
|
onMove,
|
|
2736
|
+
collection,
|
|
2568
2737
|
collectionPath,
|
|
2569
2738
|
folderOptions,
|
|
2739
|
+
history,
|
|
2570
2740
|
active,
|
|
2571
2741
|
onActivate,
|
|
2572
2742
|
onCollapse,
|
|
@@ -2579,6 +2749,8 @@ function EntryDetail({
|
|
|
2579
2749
|
documentActions?: (entry: CmsEntry) => React.ReactNode;
|
|
2580
2750
|
// Debounced in-flight draft (see ContentBrowserProps.onEntryDraft).
|
|
2581
2751
|
onEntryDraft?: (draft: EntrySaveChange, entry: CmsEntry) => void;
|
|
2752
|
+
// The focused field's path, or null (see ContentBrowserProps.onFieldFocus).
|
|
2753
|
+
onFieldFocus?: (entryId: string, path: string | null) => void;
|
|
2582
2754
|
renderField?: RenderField;
|
|
2583
2755
|
onSaveEntry?: SaveEntry;
|
|
2584
2756
|
readOnly?: boolean;
|
|
@@ -2586,9 +2758,15 @@ function EntryDetail({
|
|
|
2586
2758
|
onDeleteEntry?: (entry: CmsEntry) => Promise<void> | void;
|
|
2587
2759
|
onRename?: (entry: CmsEntry, filename: string) => Promise<void> | void;
|
|
2588
2760
|
onMove?: (entry: CmsEntry, folder: string) => Promise<void> | void;
|
|
2761
|
+
// The collection the document belongs to, shown in its Details.
|
|
2762
|
+
collection?: string;
|
|
2589
2763
|
collectionPath?: string;
|
|
2590
2764
|
// Existing folders (relative to the glob base) offered by the move autocomplete.
|
|
2591
2765
|
folderOptions?: string[];
|
|
2766
|
+
// The document-history seam. Provided → a history button appears in this
|
|
2767
|
+
// document's header and its column can show the version browser. Omitted →
|
|
2768
|
+
// the header renders exactly as it did before history existed.
|
|
2769
|
+
history?: HistoryApi;
|
|
2592
2770
|
// Desktop columns: `active` marks the focused column (accent + header press
|
|
2593
2771
|
// activates it via onActivate); onCollapse/onClose add the header rail/close
|
|
2594
2772
|
// controls. All omitted on mobile → unchanged single-detail behavior.
|
|
@@ -2609,6 +2787,36 @@ function EntryDetail({
|
|
|
2609
2787
|
const canRename = !readOnly && !!onRename;
|
|
2610
2788
|
const canMove = !readOnly && !!onMove && glob.hierarchical;
|
|
2611
2789
|
const [prompt, setPrompt] = useState<"rename" | "move" | null>(null);
|
|
2790
|
+
// The Details modal: metadata and references, opened from the menu.
|
|
2791
|
+
const [showDetails, setShowDetails] = useState(false);
|
|
2792
|
+
// A header action that failed — a delete refused because other documents
|
|
2793
|
+
// reference this one — reported inline, where the Delete was pressed.
|
|
2794
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
2795
|
+
const deleteEntry = onDeleteEntry
|
|
2796
|
+
? () => {
|
|
2797
|
+
setActionError(null);
|
|
2798
|
+
Promise.resolve(onDeleteEntry(entry)).catch((e: unknown) => {
|
|
2799
|
+
setActionError(e instanceof Error ? e.message : "Couldn’t delete the document.");
|
|
2800
|
+
});
|
|
2801
|
+
}
|
|
2802
|
+
: undefined;
|
|
2803
|
+
// Per-field "your value was replaced" counters, bumped by a restore. Only the
|
|
2804
|
+
// host-supplied body editor reads them (see FieldSlotArgs.resetNonce); every
|
|
2805
|
+
// built-in control is controlled and needs no telling.
|
|
2806
|
+
const [resetNonces, setResetNonces] = useState<Record<string, number>>({});
|
|
2807
|
+
|
|
2808
|
+
// Whether this column is showing the version browser instead of the form.
|
|
2809
|
+
// Deliberately per-column and ephemeral: history is something you open, read
|
|
2810
|
+
// and close, so it lives and dies with the column rather than in the URL.
|
|
2811
|
+
const [showHistory, setShowHistory] = useState(false);
|
|
2812
|
+
// A column reused for another document (the "preview tab" behavior) must not
|
|
2813
|
+
// carry the previous document's history view onto it — you asked to see that
|
|
2814
|
+
// document, not this one's past.
|
|
2815
|
+
const historyFor = useRef(entry.id);
|
|
2816
|
+
if (historyFor.current !== entry.id) {
|
|
2817
|
+
historyFor.current = entry.id;
|
|
2818
|
+
if (showHistory) setShowHistory(false);
|
|
2819
|
+
}
|
|
2612
2820
|
|
|
2613
2821
|
// Both branches share one value map. The title+body branch is modeled as a
|
|
2614
2822
|
// single body-source field so autosave logic is uniform.
|
|
@@ -2791,6 +2999,112 @@ function EntryDetail({
|
|
|
2791
2999
|
[commit],
|
|
2792
3000
|
);
|
|
2793
3001
|
|
|
3002
|
+
// Publish a value map the form did NOT arrive at by typing — a restore from a
|
|
3003
|
+
// past version, or a discard back to the saved document.
|
|
3004
|
+
//
|
|
3005
|
+
// Both used to write local state and stop there, which is only half the job in
|
|
3006
|
+
// a live session. The shared document is the source of truth there: every
|
|
3007
|
+
// collab control prefers its binding to the value handed to it, so a replaced
|
|
3008
|
+
// value was displayed for one frame and then overwritten by the room's
|
|
3009
|
+
// unchanged one, and no peer ever saw it. Anything that replaces values
|
|
3010
|
+
// wholesale has to come through here.
|
|
3011
|
+
//
|
|
3012
|
+
// `names` are the fields whose values were replaced; unchanged ones are left
|
|
3013
|
+
// alone so a replacement never churns the room (or nudges a peer's cursor)
|
|
3014
|
+
// over a value that did not move.
|
|
3015
|
+
const publishReplacement = useCallback(
|
|
3016
|
+
(previous: Record<string, unknown>, next: Record<string, unknown>, names: string[]) => {
|
|
3017
|
+
const moved = names.filter((name) => !sameValue(previous[name], next[name]));
|
|
3018
|
+
if (moved.length === 0) return;
|
|
3019
|
+
|
|
3020
|
+
if (entry.collab) {
|
|
3021
|
+
for (const name of moved) {
|
|
3022
|
+
// A body is a ProseMirror fragment rather than a field binding, so it
|
|
3023
|
+
// is not written here — its editor is told instead, by the nonce below.
|
|
3024
|
+
if (editFields.find((f) => f.name === name)?.source === "body") continue;
|
|
3025
|
+
writeCollabValue(entry.collab, name, previous[name], next[name]);
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
// Tell the replaced fields their value came from outside. Keyed per field
|
|
3030
|
+
// rather than one counter for the document: the body editor acts on this,
|
|
3031
|
+
// and replacing some unrelated field must not make it rebuild a body a
|
|
3032
|
+
// peer may be mid-sentence in.
|
|
3033
|
+
setResetNonces((prev) => {
|
|
3034
|
+
const bumped = { ...prev };
|
|
3035
|
+
for (const name of moved) bumped[name] = (bumped[name] ?? 0) + 1;
|
|
3036
|
+
return bumped;
|
|
3037
|
+
});
|
|
3038
|
+
},
|
|
3039
|
+
// editFields is derived from entry, captured by id.
|
|
3040
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3041
|
+
[entry.id, entry.collab],
|
|
3042
|
+
);
|
|
3043
|
+
|
|
3044
|
+
// Values taken from a past version and put back into the form.
|
|
3045
|
+
//
|
|
3046
|
+
// It is an edit like any other: the restored fields land in `values`, the
|
|
3047
|
+
// document goes unsaved, and the author saves it (or discards it) themselves.
|
|
3048
|
+
// Nothing is written to git here — history is a place you read, and rewriting
|
|
3049
|
+
// the document from it should still pass through the same review a typed edit
|
|
3050
|
+
// does.
|
|
3051
|
+
const restoreValues = useCallback(
|
|
3052
|
+
(restored: { fields: Record<string, unknown>; body?: string | null }) => {
|
|
3053
|
+
const previous = valuesRef.current;
|
|
3054
|
+
const next = { ...previous, ...restored.fields };
|
|
3055
|
+
// The body is a field in the form like any other; which one it is comes
|
|
3056
|
+
// from the schema (`source: "body"`), the same mapping buildChange uses in
|
|
3057
|
+
// the other direction. A document whose model has no body field simply
|
|
3058
|
+
// has no body to restore.
|
|
3059
|
+
const bodyField = "body" in restored ? editFields.find((f) => f.source === "body") : undefined;
|
|
3060
|
+
if (bodyField) next[bodyField.name] = restored.body ?? "";
|
|
3061
|
+
|
|
3062
|
+
const replaced = Object.keys(restored.fields);
|
|
3063
|
+
if (bodyField) replaced.push(bodyField.name);
|
|
3064
|
+
|
|
3065
|
+
// Touched under the first restored name so a restore that leaves a
|
|
3066
|
+
// required field empty shows its error immediately, rather than looking
|
|
3067
|
+
// saveable until Save is pressed.
|
|
3068
|
+
//
|
|
3069
|
+
// Committed BEFORE publishing: a collab control observing the room echoes
|
|
3070
|
+
// what it sees back through onChange, and that echo builds its next map by
|
|
3071
|
+
// spreading the current one. Publishing first would have it spread the
|
|
3072
|
+
// pre-restore map.
|
|
3073
|
+
commit(next, replaced[0] ?? SYNTHETIC_BODY);
|
|
3074
|
+
publishReplacement(previous, next, replaced);
|
|
3075
|
+
// Drilled-into groups address the pre-restore shape; a restored group
|
|
3076
|
+
// object can have different children entirely.
|
|
3077
|
+
setGroupPath([]);
|
|
3078
|
+
},
|
|
3079
|
+
// editFields is derived from entry, which commit already captures by id.
|
|
3080
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3081
|
+
[commit, entry.id, publishReplacement],
|
|
3082
|
+
);
|
|
3083
|
+
|
|
3084
|
+
// Throw away everything unsaved and go back to the document as stored.
|
|
3085
|
+
//
|
|
3086
|
+
// In a live session that is a change to the ROOM, not just to this screen: the
|
|
3087
|
+
// unsaved work is the collective state every client is looking at, and a
|
|
3088
|
+
// discard that only reverted the local form would leave the document it just
|
|
3089
|
+
// claimed to restore sitting in the CRDT, ready to come straight back.
|
|
3090
|
+
const discardEdits = useCallback(() => {
|
|
3091
|
+
const previous = valuesRef.current;
|
|
3092
|
+
const restored = seed();
|
|
3093
|
+
valuesRef.current = restored;
|
|
3094
|
+
setValues(restored);
|
|
3095
|
+
setTouched({});
|
|
3096
|
+
// The preview is rendering the draft being thrown away, and nothing else
|
|
3097
|
+
// will tell it otherwise: it is fed from edits, and a discard is the one
|
|
3098
|
+
// change to a document that produces no edit. Without this the preview
|
|
3099
|
+
// keeps showing work the author just deleted, until they type again.
|
|
3100
|
+
emitDraft(buildChange(entry.id, editFields, restored), entry);
|
|
3101
|
+
discard();
|
|
3102
|
+
publishReplacement(previous, restored, editFields.map((f) => f.name));
|
|
3103
|
+
setGroupPath([]);
|
|
3104
|
+
// seed/editFields derive from entry, captured by id.
|
|
3105
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3106
|
+
}, [entry.id, discard, emitDraft, publishReplacement]);
|
|
3107
|
+
|
|
2794
3108
|
// A field edit within the active group-drill frame: write the value at the
|
|
2795
3109
|
// frame's nested path, then commit the whole map (buildChange serializes it).
|
|
2796
3110
|
const changeInFrame = useCallback(
|
|
@@ -2805,8 +3119,54 @@ function EntryDetail({
|
|
|
2805
3119
|
// The field list + nested value object for the active frame.
|
|
2806
3120
|
const frame = resolveFrame(editFields, values, groupPath);
|
|
2807
3121
|
|
|
3122
|
+
// Which field has the keyboard. One pair of DOM focus listeners on the
|
|
3123
|
+
// column rather than an onFocus on every control: the controls are many
|
|
3124
|
+
// (inputs, selects, the ProseMirror body, plugin fields) and every one of
|
|
3125
|
+
// them renders inside the FieldControl wrapper that carries the path as
|
|
3126
|
+
// `data-cms-field` — so the element the focus landed in is enough.
|
|
3127
|
+
//
|
|
3128
|
+
// focusout fires before the next focusin, so a blur is reported a tick
|
|
3129
|
+
// late and cancelled if focus went straight to another field: moving
|
|
3130
|
+
// between two controls reads as one change, not a flicker through null.
|
|
3131
|
+
const focusRoot = useRef<View>(null);
|
|
3132
|
+
const fieldFocus = useRef(onFieldFocus);
|
|
3133
|
+
fieldFocus.current = onFieldFocus;
|
|
3134
|
+
const entryId = entry.id;
|
|
3135
|
+
useEffect(() => {
|
|
3136
|
+
if (Platform.OS !== "web") return;
|
|
3137
|
+
const node = focusRoot.current as unknown as HTMLElement | null;
|
|
3138
|
+
if (!node || typeof node.addEventListener !== "function") return;
|
|
3139
|
+
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
3140
|
+
let last: string | null = null;
|
|
3141
|
+
const report = (path: string | null) => {
|
|
3142
|
+
if (path === last) return;
|
|
3143
|
+
last = path;
|
|
3144
|
+
fieldFocus.current?.(entryId, path);
|
|
3145
|
+
};
|
|
3146
|
+
const onFocusIn = (ev: Event) => {
|
|
3147
|
+
if (pending) { clearTimeout(pending); pending = null; }
|
|
3148
|
+
const target = ev.target as Element | null;
|
|
3149
|
+
const el = target && typeof target.closest === "function" ? target.closest("[data-cms-field]") : null;
|
|
3150
|
+
report(el?.getAttribute("data-cms-field") || null);
|
|
3151
|
+
};
|
|
3152
|
+
const onFocusOut = () => {
|
|
3153
|
+
if (pending) clearTimeout(pending);
|
|
3154
|
+
pending = setTimeout(() => { pending = null; report(null); }, 0);
|
|
3155
|
+
};
|
|
3156
|
+
node.addEventListener("focusin", onFocusIn);
|
|
3157
|
+
node.addEventListener("focusout", onFocusOut);
|
|
3158
|
+
return () => {
|
|
3159
|
+
node.removeEventListener("focusin", onFocusIn);
|
|
3160
|
+
node.removeEventListener("focusout", onFocusOut);
|
|
3161
|
+
if (pending) clearTimeout(pending);
|
|
3162
|
+
// The column is going away with the field still focused: nothing in
|
|
3163
|
+
// this document has focus any more.
|
|
3164
|
+
if (last !== null) fieldFocus.current?.(entryId, null);
|
|
3165
|
+
};
|
|
3166
|
+
}, [entryId]);
|
|
3167
|
+
|
|
2808
3168
|
return (
|
|
2809
|
-
<View style={{ flex: 1 }}>
|
|
3169
|
+
<View style={{ flex: 1 }} ref={focusRoot}>
|
|
2810
3170
|
{/* breadcrumb + actions. position/zIndex lift this row (and the "..." menu
|
|
2811
3171
|
dropdown it hosts) above the content pane so the menu receives clicks. */}
|
|
2812
3172
|
<View
|
|
@@ -2854,19 +3214,7 @@ function EntryDetail({
|
|
|
2854
3214
|
{saveHandler && dirty ? (
|
|
2855
3215
|
<Pressable
|
|
2856
3216
|
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
|
-
}}
|
|
3217
|
+
onPress={discardEdits}
|
|
2870
3218
|
style={{ paddingHorizontal: t.space(2), paddingVertical: t.space(1) }}
|
|
2871
3219
|
>
|
|
2872
3220
|
<Text variant="monoSm" color="tertiary">Discard</Text>
|
|
@@ -2894,13 +3242,26 @@ function EntryDetail({
|
|
|
2894
3242
|
document's own actions, while the menu holds the destructive and
|
|
2895
3243
|
path-changing ones that should stay one level down. */}
|
|
2896
3244
|
{documentActions?.(entry)}
|
|
2897
|
-
{
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
3245
|
+
{/* History is a read of this document, not a change to it, so it sits
|
|
3246
|
+
out here with the other non-destructive actions rather than in the
|
|
3247
|
+
menu beside Delete. It toggles: pressing it again returns the column
|
|
3248
|
+
to the form. */}
|
|
3249
|
+
{history ? (
|
|
3250
|
+
<IconButton
|
|
3251
|
+
name="history"
|
|
3252
|
+
size="sm"
|
|
3253
|
+
active={showHistory}
|
|
3254
|
+
label={showHistory ? "Close history" : "Document history"}
|
|
3255
|
+
onPress={() => setShowHistory((v) => !v)}
|
|
3256
|
+
testID="document-history-toggle"
|
|
2902
3257
|
/>
|
|
2903
3258
|
) : null}
|
|
3259
|
+
<DetailMenu
|
|
3260
|
+
onDetails={() => setShowDetails(true)}
|
|
3261
|
+
onRename={canRename ? () => setPrompt("rename") : undefined}
|
|
3262
|
+
onMove={canMove ? () => setPrompt("move") : undefined}
|
|
3263
|
+
onDelete={canDelete && deleteEntry ? deleteEntry : undefined}
|
|
3264
|
+
/>
|
|
2904
3265
|
{onCollapse ? (
|
|
2905
3266
|
<IconButton name="panelLeft" size="sm" label="Collapse column" onPress={onCollapse} testID="column-collapse" />
|
|
2906
3267
|
) : null}
|
|
@@ -2909,10 +3270,35 @@ function EntryDetail({
|
|
|
2909
3270
|
) : null}
|
|
2910
3271
|
</View>
|
|
2911
3272
|
|
|
2912
|
-
{/*
|
|
3273
|
+
{/* History takes over the column's body, keeping its header: the header
|
|
3274
|
+
is what says which document you are looking at, and it carries the
|
|
3275
|
+
control that got you here and gets you back. */}
|
|
3276
|
+
{showHistory && history ? (
|
|
3277
|
+
<DocumentHistory
|
|
3278
|
+
documentId={entry.id}
|
|
3279
|
+
documentPath={entry.path}
|
|
3280
|
+
history={history}
|
|
3281
|
+
onClose={() => setShowHistory(false)}
|
|
3282
|
+
// Restoring needs somewhere for the values to go: a document that is
|
|
3283
|
+
// read-only, or that this host never wired a save for, gets the
|
|
3284
|
+
// history with no selection boxes at all rather than a form it can
|
|
3285
|
+
// dirty and never save. (saveHandler is already undefined when
|
|
3286
|
+
// readOnly, so this covers both.)
|
|
3287
|
+
onRestore={
|
|
3288
|
+
!saveHandler
|
|
3289
|
+
? undefined
|
|
3290
|
+
: (restored) => {
|
|
3291
|
+
restoreValues(restored);
|
|
3292
|
+
// Back to the form, which is where the restored values now
|
|
3293
|
+
// are and where the decision to keep them is made.
|
|
3294
|
+
setShowHistory(false);
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
/>
|
|
3298
|
+
) : /* Keyed by entry.id so controls (incl. the markdown editor) remount with
|
|
2913
3299
|
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
|
-
|
|
3300
|
+
document changes. The header above stays put; only the body scrolls. */
|
|
3301
|
+
hasFields ? (
|
|
2916
3302
|
// The document's path reaches media fields at any nesting depth through
|
|
2917
3303
|
// context: a picker inside a group or a list item needs it to interpret
|
|
2918
3304
|
// relative references, and drilling it through every level would touch
|
|
@@ -2925,6 +3311,7 @@ function EntryDetail({
|
|
|
2925
3311
|
contentContainerStyle={{ padding: t.space(5), gap: t.space(4) }}
|
|
2926
3312
|
>
|
|
2927
3313
|
{saveHandler && error ? <FormAlert message={error} onDismiss={clearError} /> : null}
|
|
3314
|
+
{actionError ? <FormAlert message={actionError} onDismiss={() => setActionError(null)} /> : null}
|
|
2928
3315
|
{groupPath.length > 0 ? (
|
|
2929
3316
|
<Pressable
|
|
2930
3317
|
testID="group-back"
|
|
@@ -2935,6 +3322,8 @@ function EntryDetail({
|
|
|
2935
3322
|
<Text variant="monoSm" color="tertiary">{frame.labels.join(" / ")}</Text>
|
|
2936
3323
|
</Pressable>
|
|
2937
3324
|
) : null}
|
|
3325
|
+
{/* Drilled into a group, every field's path starts with the group's. */}
|
|
3326
|
+
<FieldPathContext.Provider value={groupPath.join(".")}>
|
|
2938
3327
|
{frame.fields.map((f) => (
|
|
2939
3328
|
<FieldControl
|
|
2940
3329
|
key={f.name}
|
|
@@ -2950,13 +3339,19 @@ function EntryDetail({
|
|
|
2950
3339
|
collab={entry.collab}
|
|
2951
3340
|
path={[...groupPath, f.name].join(".")}
|
|
2952
3341
|
focusSignal={focusTarget && focusTarget.name === f.name ? focusTarget.nonce : undefined}
|
|
3342
|
+
resetNonce={resetNonces[f.name]}
|
|
2953
3343
|
/>
|
|
2954
3344
|
))}
|
|
3345
|
+
</FieldPathContext.Provider>
|
|
2955
3346
|
</ScrollView>
|
|
2956
3347
|
</DocumentPathProvider>
|
|
2957
3348
|
) : (
|
|
3349
|
+
// The body editor resolves the images it embeds relative to this
|
|
3350
|
+
// document, the same way a media field does.
|
|
3351
|
+
<DocumentPathProvider path={entry.path}>
|
|
2958
3352
|
<View key={entry.id} style={{ padding: t.space(5), gap: t.space(4), flex: 1 }}>
|
|
2959
3353
|
{saveHandler && error ? <FormAlert message={error} onDismiss={clearError} /> : null}
|
|
3354
|
+
{actionError ? <FormAlert message={actionError} onDismiss={() => setActionError(null)} /> : null}
|
|
2960
3355
|
<View style={{ gap: t.space(2) }}>
|
|
2961
3356
|
<Text variant="label" color="tertiary">Title</Text>
|
|
2962
3357
|
<Text variant="h2">{entry.title}</Text>
|
|
@@ -2968,8 +3363,12 @@ function EntryDetail({
|
|
|
2968
3363
|
readOnly={readOnly}
|
|
2969
3364
|
/>
|
|
2970
3365
|
</View>
|
|
3366
|
+
</DocumentPathProvider>
|
|
2971
3367
|
)}
|
|
2972
3368
|
|
|
3369
|
+
{showDetails ? (
|
|
3370
|
+
<DocumentDetails entry={entry} collection={collection} onClose={() => setShowDetails(false)} />
|
|
3371
|
+
) : null}
|
|
2973
3372
|
{prompt === "rename" && onRename ? (
|
|
2974
3373
|
<PromptDialog
|
|
2975
3374
|
title="Rename file"
|
|
@@ -3029,27 +3428,6 @@ function useMultiSelect(resetKey: string) {
|
|
|
3029
3428
|
return { checked, toggle, clear, checkedIds, checkedFolders, toggleFolder, checkedFolderKeys };
|
|
3030
3429
|
}
|
|
3031
3430
|
|
|
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
3431
|
// SelectionBar appears above the entry list when rows are checked; it offers a
|
|
3054
3432
|
// bulk move and/or delete plus a clear action (each shown only when wired).
|
|
3055
3433
|
function SelectionBar({ count, onMove, onDelete, onClear }: { count: number; onMove?: () => void; onDelete?: () => void; onClear: () => void }) {
|
|
@@ -3093,6 +3471,8 @@ type ColumnModel = {
|
|
|
3093
3471
|
canDelete?: boolean;
|
|
3094
3472
|
canRename: boolean;
|
|
3095
3473
|
canMove: boolean;
|
|
3474
|
+
// The tab's collection key, for the document's Details.
|
|
3475
|
+
collection?: string;
|
|
3096
3476
|
collectionPath?: string;
|
|
3097
3477
|
/** Set for a custom column (see OpenTab.content); `entry` is then unused. */
|
|
3098
3478
|
content?: React.ReactNode;
|
|
@@ -3192,7 +3572,7 @@ function EntriesList({
|
|
|
3192
3572
|
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
|
3193
3573
|
{showChecks ? (
|
|
3194
3574
|
<View style={{ paddingLeft: t.space(4) }}>
|
|
3195
|
-
<CheckBox value={!!checked?.[e.id]} onToggle={() => onToggleEntry?.(e.id)} />
|
|
3575
|
+
<CheckBox value={!!checked?.[e.id]} onToggle={() => onToggleEntry?.(e.id)} testID="entry-checkbox" />
|
|
3196
3576
|
</View>
|
|
3197
3577
|
) : null}
|
|
3198
3578
|
<View style={{ flex: 1 }}>
|
|
@@ -3567,9 +3947,28 @@ function ReadOnlyBanner({ notice, actionLabel, onAction }: { notice?: string; ac
|
|
|
3567
3947
|
);
|
|
3568
3948
|
}
|
|
3569
3949
|
|
|
3570
|
-
//
|
|
3571
|
-
//
|
|
3572
|
-
//
|
|
3950
|
+
// ModalOverlays renders the browser's full-screen modals — apply changes /
|
|
3951
|
+
// change request, and the protected-branch save prompt. Shared by both layouts
|
|
3952
|
+
// so every modal is reachable on mobile too.
|
|
3953
|
+
function ModalOverlays(props: ContentBrowserProps) {
|
|
3954
|
+
return (
|
|
3955
|
+
<>
|
|
3956
|
+
<ApplyModalOverlay {...props} />
|
|
3957
|
+
{props.protectedBranch ? (
|
|
3958
|
+
<ProtectedBranchModal
|
|
3959
|
+
branch={props.protectedBranch}
|
|
3960
|
+
documentLabel={props.protectedBranchDocument}
|
|
3961
|
+
suggestedName={props.protectedBranchSuggestedName}
|
|
3962
|
+
busy={props.creatingProtectedBranch}
|
|
3963
|
+
error={props.protectedBranchError}
|
|
3964
|
+
onCreate={props.onCreateBranchAndSave ?? (() => {})}
|
|
3965
|
+
onCancel={props.onCancelProtectedSave ?? (() => {})}
|
|
3966
|
+
/>
|
|
3967
|
+
) : null}
|
|
3968
|
+
</>
|
|
3969
|
+
);
|
|
3970
|
+
}
|
|
3971
|
+
|
|
3573
3972
|
function ApplyModalOverlay(props: ContentBrowserProps) {
|
|
3574
3973
|
if (!props.applyOpen) return null;
|
|
3575
3974
|
return (
|
|
@@ -3577,6 +3976,7 @@ function ApplyModalOverlay(props: ContentBrowserProps) {
|
|
|
3577
3976
|
sourceBranch={props.workspace.branch}
|
|
3578
3977
|
targetBranch={props.targetBranch ?? props.defaultBranch ?? ""}
|
|
3579
3978
|
summary={props.applySummary}
|
|
3979
|
+
reviewReason={props.applyReviewReason}
|
|
3580
3980
|
progress={props.applyProgress}
|
|
3581
3981
|
conflictCount={props.applyConflictCount}
|
|
3582
3982
|
changeRequestOpen={props.changeRequestOpen}
|
|
@@ -3756,6 +4156,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
3756
4156
|
canDelete: tab.canDelete,
|
|
3757
4157
|
canRename: !!tab.canRename,
|
|
3758
4158
|
canMove: !!tab.canMove,
|
|
4159
|
+
collection: tab.collection,
|
|
3759
4160
|
collectionPath: tab.collectionPath,
|
|
3760
4161
|
content: tab.content,
|
|
3761
4162
|
title: tab.title,
|
|
@@ -3937,7 +4338,9 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
3937
4338
|
<EntryDetail
|
|
3938
4339
|
entry={col.entry}
|
|
3939
4340
|
documentActions={props.documentActions}
|
|
4341
|
+
history={props.history}
|
|
3940
4342
|
onEntryDraft={props.onEntryDraft}
|
|
4343
|
+
onFieldFocus={props.onFieldFocus}
|
|
3941
4344
|
renderField={renderField}
|
|
3942
4345
|
onSaveEntry={props.onSaveEntry}
|
|
3943
4346
|
readOnly={col.readOnly}
|
|
@@ -3945,6 +4348,9 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
3945
4348
|
onDeleteEntry={onDeleteEntry}
|
|
3946
4349
|
onRename={col.canRename ? onRename : undefined}
|
|
3947
4350
|
onMove={col.canMove ? onMove : undefined}
|
|
4351
|
+
// A host-managed tab names its collection; a column the browser opened
|
|
4352
|
+
// itself (uncontrolled selection) belongs to the active one.
|
|
4353
|
+
collection={navLabelFor(props.sections, col.collection || props.activeNavKey) ?? (col.collection || props.activeNavKey)}
|
|
3948
4354
|
collectionPath={col.collectionPath}
|
|
3949
4355
|
folderOptions={moveFolders}
|
|
3950
4356
|
active={!showCreate && col.active}
|
|
@@ -4066,7 +4472,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4066
4472
|
const readOnlyBanner = (
|
|
4067
4473
|
<ReadOnlyBanner notice={props.readOnlyNotice} actionLabel={props.readOnlyNoticeActionLabel} onAction={props.onReadOnlyNoticeAction} />
|
|
4068
4474
|
);
|
|
4069
|
-
const
|
|
4475
|
+
const modalOverlays = <ModalOverlays {...props} />;
|
|
4070
4476
|
|
|
4071
4477
|
// The changes surface reuses the same three-pane model as Edit, so switching
|
|
4072
4478
|
// between them doesn't relayout the screen — only what each pane contains
|
|
@@ -4119,7 +4525,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4119
4525
|
conflicts={props.selectedConflicts}
|
|
4120
4526
|
onResolveConflict={props.onResolveConflict}
|
|
4121
4527
|
/>
|
|
4122
|
-
{
|
|
4528
|
+
{modalOverlays}
|
|
4123
4529
|
</AppShell>
|
|
4124
4530
|
);
|
|
4125
4531
|
}
|
|
@@ -4134,7 +4540,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4134
4540
|
<Pane flex={1} testID="pane-plugin" scroll={false}>
|
|
4135
4541
|
{props.contentSlot}
|
|
4136
4542
|
</Pane>
|
|
4137
|
-
{
|
|
4543
|
+
{modalOverlays}
|
|
4138
4544
|
</AppShell>
|
|
4139
4545
|
);
|
|
4140
4546
|
}
|
|
@@ -4174,7 +4580,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4174
4580
|
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
|
|
4175
4581
|
<Text variant="body" color="tertiary" testID="forms-empty">Select a form</Text>
|
|
4176
4582
|
</View>
|
|
4177
|
-
{
|
|
4583
|
+
{modalOverlays}
|
|
4178
4584
|
</AppShell>
|
|
4179
4585
|
);
|
|
4180
4586
|
}
|
|
@@ -4192,7 +4598,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4192
4598
|
variant="desktop"
|
|
4193
4599
|
/>
|
|
4194
4600
|
</Pane>
|
|
4195
|
-
{
|
|
4601
|
+
{modalOverlays}
|
|
4196
4602
|
</AppShell>
|
|
4197
4603
|
);
|
|
4198
4604
|
}
|
|
@@ -4219,7 +4625,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4219
4625
|
<Text variant="body" color="tertiary" testID="media-sets-empty">Select a media collection</Text>
|
|
4220
4626
|
</View>
|
|
4221
4627
|
)}
|
|
4222
|
-
{
|
|
4628
|
+
{modalOverlays}
|
|
4223
4629
|
</AppShell>
|
|
4224
4630
|
);
|
|
4225
4631
|
}
|
|
@@ -4420,7 +4826,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4420
4826
|
onClose={() => setMovePrompt(false)}
|
|
4421
4827
|
/>
|
|
4422
4828
|
) : null}
|
|
4423
|
-
{
|
|
4829
|
+
{modalOverlays}
|
|
4424
4830
|
</AppShell>
|
|
4425
4831
|
);
|
|
4426
4832
|
}
|
|
@@ -4496,7 +4902,7 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4496
4902
|
const readOnlyBanner = (
|
|
4497
4903
|
<ReadOnlyBanner notice={props.readOnlyNotice} actionLabel={props.readOnlyNoticeActionLabel} onAction={props.onReadOnlyNoticeAction} />
|
|
4498
4904
|
);
|
|
4499
|
-
const
|
|
4905
|
+
const modalOverlays = <ModalOverlays {...props} />;
|
|
4500
4906
|
|
|
4501
4907
|
// Media drills the same way a collection does — nav → list → detail — so the
|
|
4502
4908
|
// back arrow means the same thing at every level. Pressing Media lists the
|
|
@@ -4622,7 +5028,7 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4622
5028
|
testID="mobile-shell"
|
|
4623
5029
|
header={header}
|
|
4624
5030
|
title={props.surface === "changes" ? "Changes" : "Content"}
|
|
4625
|
-
overlay={
|
|
5031
|
+
overlay={modalOverlays}
|
|
4626
5032
|
>
|
|
4627
5033
|
{/* Edit / Changes surface toggle (fits its content) with the Apply button
|
|
4628
5034
|
to its right on the changes surface. The read-only notice sits below. */}
|
|
@@ -4703,7 +5109,7 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4703
5109
|
testID="mobile-entries"
|
|
4704
5110
|
scroll={false}
|
|
4705
5111
|
banner={readOnlyBanner}
|
|
4706
|
-
overlay={
|
|
5112
|
+
overlay={modalOverlays}
|
|
4707
5113
|
header={
|
|
4708
5114
|
search.open ? (
|
|
4709
5115
|
<SearchHeaderBar search={search} showFilter={facetFields.length > 0} />
|
|
@@ -4784,7 +5190,7 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4784
5190
|
<MobileScreen
|
|
4785
5191
|
testID="mobile-entry"
|
|
4786
5192
|
banner={readOnlyBanner}
|
|
4787
|
-
overlay={
|
|
5193
|
+
overlay={modalOverlays}
|
|
4788
5194
|
header={
|
|
4789
5195
|
<>
|
|
4790
5196
|
<IconButton name="chevronLeft" onPress={backToEntries} size="md" label="Back" />
|
|
@@ -4812,7 +5218,9 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4812
5218
|
<EntryDetail
|
|
4813
5219
|
entry={entry}
|
|
4814
5220
|
documentActions={props.documentActions}
|
|
5221
|
+
history={props.history}
|
|
4815
5222
|
onEntryDraft={props.onEntryDraft}
|
|
5223
|
+
onFieldFocus={props.onFieldFocus}
|
|
4816
5224
|
renderField={renderField}
|
|
4817
5225
|
onSaveEntry={props.onSaveEntry}
|
|
4818
5226
|
readOnly={readOnly}
|
|
@@ -4820,6 +5228,7 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4820
5228
|
onDeleteEntry={onDeleteEntry}
|
|
4821
5229
|
onRename={onRename}
|
|
4822
5230
|
onMove={onMove}
|
|
5231
|
+
collection={navLabelFor(props.sections, props.activeNavKey) ?? props.activeNavKey}
|
|
4823
5232
|
collectionPath={collectionPath}
|
|
4824
5233
|
folderOptions={moveFolders}
|
|
4825
5234
|
/>
|
|
@@ -5071,7 +5480,9 @@ export function ContentBrowser(props: ContentBrowserProps) {
|
|
|
5071
5480
|
// object store) renders media fields read-only.
|
|
5072
5481
|
return (
|
|
5073
5482
|
<MediaProvider api={props.media}>
|
|
5074
|
-
|
|
5483
|
+
<ReferenceProvider api={props.references}>
|
|
5484
|
+
{isDesktop ? <DesktopBrowser {...withSections} /> : <MobileBrowser {...withSections} />}
|
|
5485
|
+
</ReferenceProvider>
|
|
5075
5486
|
</MediaProvider>
|
|
5076
5487
|
);
|
|
5077
5488
|
}
|
|
@@ -5082,14 +5493,20 @@ function mediaSetLabel(sets: MediaSetInfo[], name: string): string {
|
|
|
5082
5493
|
return set ? titleCaseWord(set.name) : titleCaseWord(name);
|
|
5083
5494
|
}
|
|
5084
5495
|
|
|
5085
|
-
// PaneTitle is the content pane's heading
|
|
5086
|
-
//
|
|
5496
|
+
// PaneTitle is the content pane's heading for the surfaces with no controls
|
|
5497
|
+
// beside it — Changes, Media, Forms.
|
|
5498
|
+
//
|
|
5499
|
+
// Deliberately the same Text the collection header uses (`listHeader`), and
|
|
5500
|
+
// nothing else. Pane's header row already supplies the height, the horizontal
|
|
5501
|
+
// padding and the rule beneath, so the padded wrapper this used to add sat
|
|
5502
|
+
// inside that padding and pushed the title a step right of every collection's —
|
|
5503
|
+
// at `body` rather than `h3`, so it read a size smaller too. Switching between
|
|
5504
|
+
// Posts and Media moved the heading twice over.
|
|
5087
5505
|
function PaneTitle({ title }: { title: string }) {
|
|
5088
|
-
const t = useTheme();
|
|
5089
5506
|
return (
|
|
5090
|
-
<
|
|
5091
|
-
|
|
5092
|
-
</
|
|
5507
|
+
<Text variant="h3" weight="semibold" style={{ flex: 1 }}>
|
|
5508
|
+
{title}
|
|
5509
|
+
</Text>
|
|
5093
5510
|
);
|
|
5094
5511
|
}
|
|
5095
5512
|
|