@rebasepro/admin 0.13.0 → 0.13.1-canary.g18cfeb7
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/dist/{CollectionEditorDialog-CjXkWZ64.js → CollectionEditorDialog-CUFXevVg.js} +3 -3
- package/dist/{CollectionEditorDialog-CjXkWZ64.js.map → CollectionEditorDialog-CUFXevVg.js.map} +1 -1
- package/dist/{PropertyEditView-W8I3YV89.js → PropertyEditView-DDUC7giL.js} +2 -2
- package/dist/{PropertyEditView-W8I3YV89.js.map → PropertyEditView-DDUC7giL.js.map} +1 -1
- package/dist/{RouterCollectionsStudioView-BsLTNoqX.js → RouterCollectionsStudioView-vzS848Gf.js} +9 -13
- package/dist/RouterCollectionsStudioView-vzS848Gf.js.map +1 -0
- package/dist/collection_editor/serializable_types.d.ts +1 -0
- package/dist/collection_editor_ui.js +4 -4
- package/dist/components/AdditionalFieldValue.d.ts +25 -0
- package/dist/components/CollectionViewBinding/CollectionViewBinding.d.ts +16 -0
- package/dist/components/CollectionViewBinding/SlotValue.d.ts +63 -0
- package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +17 -4
- package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +12 -0
- package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +55 -5
- package/dist/components/DetailViewBinding.d.ts +15 -0
- package/dist/components/DrawerNavigationGroup.d.ts +9 -6
- package/dist/components/DrawerNavigationItem.d.ts +16 -6
- package/dist/components/EditViewBinding.d.ts +30 -1
- package/dist/components/EntityDisplayHeader.d.ts +53 -0
- package/dist/components/EntityIdentityBar.d.ts +43 -1
- package/dist/components/EntityViewBinding.d.ts +60 -6
- package/dist/components/RebaseLayout.d.ts +1 -1
- package/dist/components/RebaseShell.d.ts +1 -1
- package/dist/components/RelationSelector.d.ts +8 -1
- package/dist/components/UserSelector.d.ts +8 -1
- package/dist/components/app/Scaffold.d.ts +9 -3
- package/dist/components/index.d.ts +2 -0
- package/dist/{export-J79rMoNj.js → export-5bl2EoSs.js} +2 -2
- package/dist/{export-J79rMoNj.js.map → export-5bl2EoSs.js.map} +1 -1
- package/dist/form/components/FieldBlock.d.ts +58 -4
- package/dist/form/components/FormRail.d.ts +13 -0
- package/dist/form/components/FormSections.d.ts +15 -1
- package/dist/form/components/useRailVisible.d.ts +5 -4
- package/dist/form/form_utils.d.ts +38 -1
- package/dist/{history-D7L_JlLO.js → history-BuKKAhoY.js} +4 -4
- package/dist/{history-D7L_JlLO.js.map → history-BuKKAhoY.js.map} +1 -1
- package/dist/hooks/useEntityDisplay.d.ts +102 -0
- package/dist/hooks/useEntityDisplayTitle.d.ts +18 -9
- package/dist/{import-BvYl--U4.js → import-BhdWs34H.js} +3 -3
- package/dist/{import-BvYl--U4.js.map → import-BhdWs34H.js.map} +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +152 -57
- package/dist/index.js.map +1 -1
- package/dist/preview/compact_text.d.ts +23 -0
- package/dist/preview/components/BooleanPreview.d.ts +3 -1
- package/dist/preview/property_previews/ArrayOfStringsPreview.d.ts +1 -1
- package/dist/preview/property_previews/ArrayOneOfPreview.d.ts +1 -1
- package/dist/preview/property_previews/ArrayPropertyPreview.d.ts +1 -1
- package/dist/preview/property_previews/MapPropertyPreview.d.ts +1 -1
- package/dist/preview/property_previews/StringPropertyPreview.d.ts +1 -1
- package/dist/routes/RebaseRoute.d.ts +1 -1
- package/dist/types/components/PropertyPreviewProps.d.ts +19 -0
- package/dist/util/number_format.d.ts +14 -0
- package/dist/util/previews.d.ts +9 -2
- package/dist/{util-GiwPgxnL.js → util-DNG7iK0w.js} +3756 -2300
- package/dist/util-DNG7iK0w.js.map +1 -0
- package/package.json +9 -9
- package/src/collection_editor/serializable_types.ts +1 -0
- package/src/collection_editor/ui/collection_editor/CollectionsStudioView.tsx +9 -11
- package/src/components/AdditionalFieldValue.tsx +68 -0
- package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +7 -8
- package/src/components/CollectionTableBinding/PropertyTableCell.tsx +2 -2
- package/src/components/CollectionTableBinding/column_utils.tsx +15 -6
- package/src/components/CollectionTableBinding/internal/popup_field/PopupFormField.tsx +2 -2
- package/src/components/CollectionViewBinding/BoardCardBinding.tsx +26 -27
- package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +723 -348
- package/src/components/CollectionViewBinding/CollectionViewActions.tsx +1 -1
- package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +38 -8
- package/src/components/CollectionViewBinding/EntityCardBinding.tsx +23 -36
- package/src/components/CollectionViewBinding/SlotValue.tsx +156 -0
- package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +23 -7
- package/src/components/CollectionViewBinding/SplitListShowButton.tsx +28 -0
- package/src/components/CollectionViewBinding/SplitListView.tsx +33 -2
- package/src/components/CollectionViewBinding/useBoardDataController.tsx +12 -3
- package/src/components/CollectionViewBinding/usePreviewSlots.ts +183 -64
- package/src/components/CollectionViewBinding/utils.ts +7 -4
- package/src/components/DefaultDrawer.tsx +19 -23
- package/src/components/DetailViewBinding.tsx +93 -56
- package/src/components/DrawerNavigationGroup.tsx +16 -39
- package/src/components/DrawerNavigationItem.tsx +44 -16
- package/src/components/EditViewBinding.tsx +215 -42
- package/src/components/EntityDisplayHeader.tsx +170 -0
- package/src/components/EntityIdentityBar.tsx +163 -23
- package/src/components/EntityInspector.tsx +24 -6
- package/src/components/EntityPreviewBinding.tsx +58 -18
- package/src/components/EntityViewBinding.tsx +342 -41
- package/src/components/InlineEntityPreview.tsx +10 -10
- package/src/components/RebaseLayout.tsx +2 -2
- package/src/components/RebaseShell.tsx +2 -2
- package/src/components/ReferenceTable/SelectionTableBinding.tsx +4 -2
- package/src/components/RelationSelector.tsx +15 -4
- package/src/components/SideDialogs.tsx +15 -3
- package/src/components/SidePanelBinding.tsx +122 -66
- package/src/components/UserSelector.tsx +15 -4
- package/src/components/app/Scaffold.tsx +112 -10
- package/src/components/history/LastEditedByIndicator.tsx +7 -12
- package/src/components/index.ts +3 -0
- package/src/data_import/utils/data.ts +9 -1
- package/src/form/EntityForm.tsx +64 -52
- package/src/form/EntityFormBinding.tsx +24 -11
- package/src/form/PropertyFieldBinding.tsx +2 -2
- package/src/form/components/FieldBlock.tsx +80 -10
- package/src/form/components/FormRail.tsx +19 -2
- package/src/form/components/FormSections.tsx +32 -5
- package/src/form/components/StorageUploadProgress.tsx +9 -3
- package/src/form/components/useRailVisible.tsx +5 -4
- package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +2 -2
- package/src/form/field_bindings/BinaryFieldBinding.tsx +10 -5
- package/src/form/field_bindings/BlockFieldBinding.tsx +3 -3
- package/src/form/field_bindings/GeopointFieldBinding.tsx +23 -16
- package/src/form/field_bindings/MapFieldBinding.tsx +2 -2
- package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +13 -7
- package/src/form/field_bindings/MultiSelectFieldBinding.tsx +7 -1
- package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +7 -1
- package/src/form/field_bindings/ReferenceFieldBinding.tsx +1 -1
- package/src/form/field_bindings/RelationFieldBinding.tsx +5 -2
- package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -3
- package/src/form/field_bindings/StorageUploadFieldBinding.tsx +2 -2
- package/src/form/field_bindings/UserSelectFieldBinding.tsx +4 -1
- package/src/form/field_bindings/VectorFieldBinding.tsx +6 -4
- package/src/form/form_utils.ts +78 -0
- package/src/hooks/navigation/useBuildCollectionRegistryController.tsx +29 -5
- package/src/hooks/useEntityDisplay.tsx +262 -0
- package/src/hooks/useEntityDisplayTitle.tsx +27 -38
- package/src/index.ts +9 -1
- package/src/preview/PropertyPreview.tsx +25 -5
- package/src/preview/compact_text.ts +68 -0
- package/src/preview/components/BooleanPreview.tsx +5 -2
- package/src/preview/components/StorageThumbnail.tsx +5 -2
- package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +6 -3
- package/src/preview/property_previews/ArrayOfStringsPreview.tsx +26 -1
- package/src/preview/property_previews/ArrayOneOfPreview.tsx +10 -1
- package/src/preview/property_previews/ArrayPropertyPreview.tsx +10 -1
- package/src/preview/property_previews/MapPropertyPreview.tsx +52 -1
- package/src/preview/property_previews/NumberPropertyPreview.tsx +9 -1
- package/src/preview/property_previews/StringPropertyPreview.tsx +8 -5
- package/src/routes/RebaseRoute.tsx +62 -4
- package/src/types/components/PropertyPreviewProps.tsx +21 -0
- package/src/util/number_format.ts +46 -0
- package/src/util/previews.ts +8 -55
- package/src/util/property_utils.tsx +6 -1
- package/dist/RouterCollectionsStudioView-BsLTNoqX.js.map +0 -1
- package/dist/util-GiwPgxnL.js.map +0 -1
|
@@ -38,13 +38,15 @@ import {
|
|
|
38
38
|
useSlot,
|
|
39
39
|
getIcon
|
|
40
40
|
} from "@rebasepro/app";
|
|
41
|
-
import { getEntityFromMemoryCache } from "@rebasepro/app";
|
|
41
|
+
import { getEntityFromMemoryCache, removeEntityFromMemoryCache, saveEntityToMemoryCache } from "@rebasepro/app";
|
|
42
42
|
import { EntityFormBinding } from "../form";
|
|
43
|
+
import { getEditHandoffValues } from "../form/form_utils";
|
|
43
44
|
import type { EntityFormBindingProps } from "../form";
|
|
44
45
|
import type { OnUpdateParams } from "../types/components/EntityFormProps";
|
|
45
46
|
import { EditFormActions } from "./EditFormActions";
|
|
46
47
|
import { EntityIdentityBar } from "./EntityIdentityBar";
|
|
47
48
|
import { SplitListCloseButton } from "./CollectionViewBinding/SplitListCloseButton";
|
|
49
|
+
import { SplitListShowButton } from "./CollectionViewBinding/SplitListShowButton";
|
|
48
50
|
import { useRecordActions } from "../hooks/useRecordActions";
|
|
49
51
|
import { useSideDialogContext } from "./SideDialogs";
|
|
50
52
|
import { useAdminContext } from "../hooks/useAdminContext";
|
|
@@ -64,6 +66,19 @@ export type BarActionsParams = {
|
|
|
64
66
|
status: EntityStatus,
|
|
65
67
|
path: string,
|
|
66
68
|
entityId?: string | number;
|
|
69
|
+
/** Whether `values` differ from what is stored. */
|
|
70
|
+
dirty: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Hand the edit in progress to the layout this action is about to open, so
|
|
73
|
+
* the record arrives showing — and still holding — what the user typed here.
|
|
74
|
+
* Call it from any action that changes layout without leaving the record;
|
|
75
|
+
* it decides on its own whether there is anything worth carrying, so a
|
|
76
|
+
* record nobody edited still arrives clean.
|
|
77
|
+
*
|
|
78
|
+
* Only supplied where there is a form to carry: the detail view passes no
|
|
79
|
+
* such thing.
|
|
80
|
+
*/
|
|
81
|
+
carryEdit?: () => void;
|
|
67
82
|
};
|
|
68
83
|
|
|
69
84
|
export type OnTabChangeParams<M extends Record<string, unknown>> = {
|
|
@@ -89,6 +104,16 @@ export interface EditViewBindingProps<M extends Record<string, unknown> = Record
|
|
|
89
104
|
onSaved?: (params: OnUpdateParams) => void;
|
|
90
105
|
onTabChange?: (props: OnTabChangeParams<M>) => void;
|
|
91
106
|
navigateBack?: () => void;
|
|
107
|
+
/**
|
|
108
|
+
* Dismiss this record — the ✕ at the identity bar's trailing edge, and what
|
|
109
|
+
* "save and close" reaches once the save lands.
|
|
110
|
+
*
|
|
111
|
+
* Set by the layouts that own the record's place on screen but are not side
|
|
112
|
+
* dialogs: the split, where dismissing is navigating back to the collection.
|
|
113
|
+
* The side panel and the dialog close through {@link useSideDialogContext}
|
|
114
|
+
* instead, which is what runs their unsaved-changes prompt.
|
|
115
|
+
*/
|
|
116
|
+
onCloseRequest?: () => void;
|
|
92
117
|
layout?: "side_panel" | "full_screen" | "split" | "dialog";
|
|
93
118
|
barActions?: (params: BarActionsParams) => React.ReactNode;
|
|
94
119
|
/**
|
|
@@ -96,6 +121,12 @@ export interface EditViewBindingProps<M extends Record<string, unknown> = Record
|
|
|
96
121
|
* of the breadcrumb — the side panel's close button.
|
|
97
122
|
*/
|
|
98
123
|
barActionsStart?: React.ReactNode;
|
|
124
|
+
/**
|
|
125
|
+
* Full screen only: bring the list back beside this record. Set when the
|
|
126
|
+
* record was opened out of a split collection, so folding the list away is
|
|
127
|
+
* reversible; it takes the place of the back arrow when present.
|
|
128
|
+
*/
|
|
129
|
+
onShowList?: () => void;
|
|
99
130
|
formProps?: Partial<EntityFormBindingProps<M>>,
|
|
100
131
|
/**
|
|
101
132
|
* Pre-populate the form with these values when creating a new entity.
|
|
@@ -127,9 +158,39 @@ export function EditViewBinding<M extends Record<string, unknown>>({
|
|
|
127
158
|
useCache: false
|
|
128
159
|
});
|
|
129
160
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
161
|
+
// The edit handed over by another layout of this same record — see
|
|
162
|
+
// {@link carryEdit} below for the sending half.
|
|
163
|
+
//
|
|
164
|
+
// Read once per record and consumed: this is a handoff, and an edit left in
|
|
165
|
+
// the channel after it has been picked up is one that reopens the record
|
|
166
|
+
// dirty on a later, unrelated visit, carrying values the user walked away
|
|
167
|
+
// from. Held in a ref rather than re-read each render, because the read has
|
|
168
|
+
// to survive the clear — {@link EntityFormBinding} measures the
|
|
169
|
+
// local-changes banner against what the form opens showing, and a prop that
|
|
170
|
+
// reverted to `undefined` once the cache was cleared would raise the banner
|
|
171
|
+
// over the very edit that was just carried in.
|
|
172
|
+
//
|
|
173
|
+
// A copy is keyed with the new records, not under the record it was copied
|
|
174
|
+
// from: it is going to be stored as its own row, and both the handoff and
|
|
175
|
+
// the local-changes backup have always keyed it that way from the writing
|
|
176
|
+
// side. Reading it under `path/id` meant a copy in progress was written to
|
|
177
|
+
// one key and looked for under another.
|
|
178
|
+
const carriesAsNew = props.copy || entityId === undefined;
|
|
179
|
+
const handoffKey = carriesAsNew ? props.path + "#new" : props.path + "/" + entityId;
|
|
180
|
+
const handoff = useRef<{ key: string, values: object | undefined } | null>(null);
|
|
181
|
+
if (handoff.current?.key !== handoffKey) {
|
|
182
|
+
handoff.current = {
|
|
183
|
+
key: handoffKey,
|
|
184
|
+
values: getEntityFromMemoryCache(handoffKey)
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
removeEntityFromMemoryCache(handoffKey);
|
|
189
|
+
}, [handoffKey]);
|
|
190
|
+
|
|
191
|
+
const initialDirtyValues = carriesAsNew
|
|
192
|
+
? (props.defaultValues ?? handoff.current.values)
|
|
193
|
+
: handoff.current.values;
|
|
133
194
|
|
|
134
195
|
const { canEdit: canEditHook } = usePermissions();
|
|
135
196
|
|
|
@@ -186,12 +247,14 @@ export function EditViewBindingInner<M extends Record<string, unknown>>({
|
|
|
186
247
|
onSaved,
|
|
187
248
|
onTabChange,
|
|
188
249
|
navigateBack,
|
|
250
|
+
onCloseRequest,
|
|
189
251
|
entity,
|
|
190
252
|
initialDirtyValues,
|
|
191
253
|
dataLoading,
|
|
192
254
|
layout = "side_panel",
|
|
193
255
|
barActions,
|
|
194
256
|
barActionsStart,
|
|
257
|
+
onShowList,
|
|
195
258
|
status,
|
|
196
259
|
setStatus,
|
|
197
260
|
formProps,
|
|
@@ -271,7 +334,9 @@ parentEntityIds,
|
|
|
271
334
|
}
|
|
272
335
|
}, [selectedTabProp, defaultSelectedView]);
|
|
273
336
|
|
|
274
|
-
|
|
337
|
+
// See DetailViewBinding: tabs are governed by `hideFromEntityViews`, the
|
|
338
|
+
// drawer by `hideFromNavigation`. They are separate roles.
|
|
339
|
+
const childViews = getAdminEntityChildViews(collection).filter(v => !v.collection.hideFromEntityViews);
|
|
275
340
|
const subcollections = childViews.map(v => v.collection);
|
|
276
341
|
const subcollectionsCount = subcollections?.length ?? 0;
|
|
277
342
|
const customViews = collection.entityViews ?? [];
|
|
@@ -405,8 +470,8 @@ parentEntityIds,
|
|
|
405
470
|
|
|
406
471
|
const displayTitle = useEntityDisplayTitle({
|
|
407
472
|
collection,
|
|
473
|
+
entity: usedEntity,
|
|
408
474
|
values: (formContext?.values ?? usedEntity?.values) as Record<string, unknown> | undefined,
|
|
409
|
-
entityId,
|
|
410
475
|
status
|
|
411
476
|
});
|
|
412
477
|
|
|
@@ -420,6 +485,35 @@ parentEntityIds,
|
|
|
420
485
|
|| (status === "existing" && !formex?.dirty)
|
|
421
486
|
);
|
|
422
487
|
|
|
488
|
+
/**
|
|
489
|
+
* Hand this record's edit in progress to the layout about to replace this
|
|
490
|
+
* one — the split's "hide list", full screen's "show list", the side panel's
|
|
491
|
+
* "open full screen". None of them is a navigation *away* from the record:
|
|
492
|
+
* the same record is about to be shown by a different form, and what the
|
|
493
|
+
* user has typed belongs to the record, not to the pane it was typed in.
|
|
494
|
+
*
|
|
495
|
+
* Every control that changes layout has to call this, and none of them is
|
|
496
|
+
* blocked by the unsaved-changes prompt — the prompt asks about leaving, and
|
|
497
|
+
* these do not leave. What used to happen instead is that the edit reached
|
|
498
|
+
* the next layout only as its local-changes backup, which is the channel for
|
|
499
|
+
* a draft left behind by a closed tab: the record reopened *clean*, with a
|
|
500
|
+
* banner offering to apply changes the user had never walked away from.
|
|
501
|
+
*/
|
|
502
|
+
const carryEdit = useCallback(() => {
|
|
503
|
+
const carried = formex
|
|
504
|
+
? getEditHandoffValues<M>({
|
|
505
|
+
status,
|
|
506
|
+
dirty: formex.dirty,
|
|
507
|
+
values: formex.values,
|
|
508
|
+
touched: formex.touched,
|
|
509
|
+
storedValues: usedEntity?.values
|
|
510
|
+
})
|
|
511
|
+
: undefined;
|
|
512
|
+
if (!carried) return;
|
|
513
|
+
const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
|
|
514
|
+
saveEntityToMemoryCache(key, carried);
|
|
515
|
+
}, [formex, status, path, entityId, usedEntity]);
|
|
516
|
+
|
|
423
517
|
const [inspectorTab, setInspectorTab] = useState<InspectorTab | null>(null);
|
|
424
518
|
// A save adds a revision, and the inspector can be open while it happens:
|
|
425
519
|
// Save lives in the identity bar, above the panel. Counting saves gives the
|
|
@@ -429,7 +523,26 @@ parentEntityIds,
|
|
|
429
523
|
/* ---- record actions, in the bar's overflow menu ---------------------- */
|
|
430
524
|
|
|
431
525
|
const sideDialogContext = useSideDialogContext();
|
|
432
|
-
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Whether the save in flight was asked to dismiss the record afterwards.
|
|
529
|
+
*
|
|
530
|
+
* A ref, and for the reason {@link SideDialogs} keeps its own as one: the
|
|
531
|
+
* flag is raised and the form submitted in a single click handler, and the
|
|
532
|
+
* `onSaved` that has to act on it was captured before React could re-render.
|
|
533
|
+
* As state it read one save late — the first "save and close" saved and
|
|
534
|
+
* stayed open, and the *next* save closed the record.
|
|
535
|
+
*
|
|
536
|
+
* The side panel and the dialog use the side-dialog controller's flag,
|
|
537
|
+
* which their own close path consumes; this one covers the layouts that
|
|
538
|
+
* close by calling {@link onCloseRequest} instead.
|
|
539
|
+
*/
|
|
540
|
+
const pendingCloseRef = useRef(false);
|
|
541
|
+
|
|
542
|
+
// The split can close after a save the same way the overlays do — through
|
|
543
|
+
// the callback its layout supplies. Without one there is nothing to close.
|
|
544
|
+
const canCloseAfterSave = layout === "side_panel" || layout === "dialog"
|
|
545
|
+
|| (layout === "split" && Boolean(onCloseRequest));
|
|
433
546
|
|
|
434
547
|
const recordActions = useRecordActions({
|
|
435
548
|
collection,
|
|
@@ -524,35 +637,39 @@ parentEntityIds,
|
|
|
524
637
|
const FormViewBuilder = formViewConfig?.Builder ? resolveComponentRef<EntityCustomViewParams>(formViewConfig.Builder as ComponentRef<EntityCustomViewParams>) : null;
|
|
525
638
|
const formViewIncludeActions = formViewConfig?.includeActions !== false;
|
|
526
639
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
640
|
+
// Without edit permission the record is rendered read-only — the same
|
|
641
|
+
// sections, spans and rail as the form, so losing the permission changes the
|
|
642
|
+
// controls and nothing else. The `h4` that used to head this repeated the
|
|
643
|
+
// collection name the identity bar above it already carries.
|
|
644
|
+
const entityReadOnlyView = !canEdit && entity ? (
|
|
645
|
+
FormViewBuilder && readOnlyFormContext
|
|
646
|
+
? <div className={cls(
|
|
647
|
+
"flex-1 flex flex-row w-full overflow-y-auto justify-center",
|
|
648
|
+
(canEdit || !mainViewVisible || selectedSecondaryForm) ? "hidden" : ""
|
|
649
|
+
)}>
|
|
650
|
+
<div className={"w-full max-w-3xl 2xl:max-w-4xl flex flex-col pt-6 pb-16 px-5 sm:px-8"}>
|
|
651
|
+
<ErrorBoundary>
|
|
652
|
+
<Suspense fallback={<CircularProgressCenter />}>
|
|
653
|
+
<FormViewBuilder
|
|
654
|
+
collection={collection}
|
|
655
|
+
parentCollectionSlugs={parentCollectionSlugs} parentEntityIds={parentEntityIds}
|
|
656
|
+
entity={usedEntity}
|
|
657
|
+
modifiedValues={usedEntity?.values}
|
|
658
|
+
formContext={readOnlyFormContext as FormContext<Record<string, unknown>>}
|
|
659
|
+
/>
|
|
660
|
+
</Suspense>
|
|
661
|
+
</ErrorBoundary>
|
|
662
|
+
</div>
|
|
663
|
+
</div>
|
|
664
|
+
: <EntityViewBinding
|
|
665
|
+
className={cls((canEdit || !mainViewVisible || selectedSecondaryForm) ? "hidden" : "")}
|
|
666
|
+
entity={entity}
|
|
667
|
+
path={path}
|
|
668
|
+
collection={collection}
|
|
669
|
+
asPage
|
|
670
|
+
openEntityMode={layout}
|
|
671
|
+
formContext={readOnlyFormContext}/>
|
|
672
|
+
) : null;
|
|
556
673
|
|
|
557
674
|
const entityView = FormViewBuilder ? (
|
|
558
675
|
// formView.Builder replaces the default form
|
|
@@ -609,8 +726,32 @@ parentEntityIds,
|
|
|
609
726
|
...params,
|
|
610
727
|
selectedTab: MAIN_TAB_VALUE === activeTab ? undefined : activeTab
|
|
611
728
|
};
|
|
729
|
+
// Two caller-supplied callbacks for one save, and either may
|
|
730
|
+
// navigate — the layouts' `onSaved` all do. Where both do, the
|
|
731
|
+
// second one's destination is what survives (docs/bug-classes.md
|
|
732
|
+
// #28), so `formProps.onSaved` is deliberately last: it belongs
|
|
733
|
+
// to whoever embedded this view, which outranks the layout.
|
|
612
734
|
onSaved?.(res);
|
|
613
735
|
formProps?.onSaved?.(res);
|
|
736
|
+
|
|
737
|
+
// After `onSaved`, never instead of it: the layout's own handler
|
|
738
|
+
// is what lowers its dirty flag, and closing is a navigation the
|
|
739
|
+
// unsaved-changes blocker would otherwise stop on values that
|
|
740
|
+
// are already stored.
|
|
741
|
+
//
|
|
742
|
+
// And after this handler returns, not inside it. The layouts
|
|
743
|
+
// that close this way close by navigating, and they have just
|
|
744
|
+
// navigated themselves — the split leaves `…/edit` for the
|
|
745
|
+
// record's own URL. Deferring keeps the close unambiguously
|
|
746
|
+
// last, which is what decides it: of two navigations raised for
|
|
747
|
+
// one action the final one survives, measured in
|
|
748
|
+
// `router_two_navigations_one_handler.test.tsx`. It also lets
|
|
749
|
+
// the state those handlers just set settle before the blocker
|
|
750
|
+
// reads it. See docs/bug-classes.md #28.
|
|
751
|
+
if (pendingCloseRef.current) {
|
|
752
|
+
pendingCloseRef.current = false;
|
|
753
|
+
setTimeout(() => onCloseRequest?.(), 0);
|
|
754
|
+
}
|
|
614
755
|
}}
|
|
615
756
|
Builder={resolveComponentRef(selectedSecondaryForm?.Builder as ComponentRef<EntityCustomViewParams<M>> | undefined) as React.ComponentType<EntityCustomViewParams<M>> | undefined}
|
|
616
757
|
/>
|
|
@@ -673,6 +814,7 @@ parentEntityIds,
|
|
|
673
814
|
<IconButton
|
|
674
815
|
size="small"
|
|
675
816
|
onClick={() => {
|
|
817
|
+
carryEdit();
|
|
676
818
|
const editSuffix = collection.defaultEntityAction === "view" ? "/edit" : "";
|
|
677
819
|
const entityUrl = urlController.buildUrlCollectionPath(`${path}/${entityId}${editSuffix}`);
|
|
678
820
|
navigate(`${entityUrl}#full`);
|
|
@@ -687,6 +829,9 @@ parentEntityIds,
|
|
|
687
829
|
|
|
688
830
|
<EntityIdentityBar
|
|
689
831
|
collection={collection as AdminCollection}
|
|
832
|
+
collectionUrl={layout === "full_screen" || layout === "split"
|
|
833
|
+
? withViewMode(urlController.buildUrlCollectionPath(path))
|
|
834
|
+
: undefined}
|
|
690
835
|
title={displayTitle}
|
|
691
836
|
entityId={status === "existing" ? entityId : undefined}
|
|
692
837
|
status={status}
|
|
@@ -696,29 +841,55 @@ parentEntityIds,
|
|
|
696
841
|
saveDisabled={!canEdit || saveDisabled}
|
|
697
842
|
// Full screen replaces the collection entirely, leaving browser Back
|
|
698
843
|
// as the only route to it. The detail view has carried this arrow
|
|
699
|
-
// for that reason; the edit view is reached the same way.
|
|
700
|
-
|
|
844
|
+
// for that reason; the edit view is reached the same way. Where the
|
|
845
|
+
// list can be unfolded back beside the record, that chevron reaches
|
|
846
|
+
// the same collection and the arrow beside it is noise.
|
|
847
|
+
onBack={layout === "full_screen" && !onShowList
|
|
701
848
|
? () => navigate(withViewMode(urlController.buildUrlCollectionPath(path)))
|
|
702
849
|
: undefined}
|
|
703
850
|
onSave={canEdit && formContext ? () => {
|
|
704
851
|
sideDialogContext.setPendingClose?.(false);
|
|
852
|
+
pendingCloseRef.current = false;
|
|
705
853
|
formContext.submit();
|
|
706
854
|
} : undefined}
|
|
707
855
|
onSaveAndClose={canEdit && formContext && canCloseAfterSave ? () => {
|
|
856
|
+
// Lowered again once the submit settles. A submit the form
|
|
857
|
+
// rejects never reaches `onSaved`, so nothing would consume the
|
|
858
|
+
// flag and the *next* save — a keyboard ⌘S, say — would close
|
|
859
|
+
// the panel out from under an edit nobody asked to finish. A
|
|
860
|
+
// save that succeeds has already closed by the time this runs.
|
|
861
|
+
if (layout === "split") {
|
|
862
|
+
pendingCloseRef.current = true;
|
|
863
|
+
Promise.resolve(formContext.submit())
|
|
864
|
+
.finally(() => { pendingCloseRef.current = false; });
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
708
867
|
sideDialogContext.setPendingClose?.(true);
|
|
709
|
-
formContext.submit()
|
|
868
|
+
Promise.resolve(formContext.submit())
|
|
869
|
+
.finally(() => sideDialogContext.setPendingClose?.(false));
|
|
710
870
|
} : undefined}
|
|
871
|
+
// Welded to Save in the split, its own button in the overlays.
|
|
872
|
+
saveAndClosePlacement={layout === "split" ? "menu" : "button"}
|
|
873
|
+
onClose={onCloseRequest}
|
|
711
874
|
onDiscard={canEdit && formContext ? () => formContext.formex.resetForm() : undefined}
|
|
712
875
|
onInspect={includeJsonView ? () => setInspectorTab("json") : undefined}
|
|
713
876
|
onViewHistory={includeHistoryView ? () => setInspectorTab("history") : undefined}
|
|
877
|
+
externalLink={usedEntity
|
|
878
|
+
? customizationController?.entityLinkBuilder?.({ entity: usedEntity })
|
|
879
|
+
: undefined}
|
|
714
880
|
recordActions={recordActionItems}
|
|
715
881
|
pluginActions={formPluginActions}
|
|
716
882
|
leading={<>
|
|
717
883
|
{barActionsStart}
|
|
718
884
|
{/* Split view: closing the list is opening this record
|
|
719
885
|
full screen, and the control for it belongs at this
|
|
720
|
-
bar's leading edge.
|
|
721
|
-
|
|
886
|
+
bar's leading edge. Full screen carries its mirror.
|
|
887
|
+
Both change layout, so both hand the edit over. */}
|
|
888
|
+
{layout === "split" && <SplitListCloseButton onBeforeHide={carryEdit}/>}
|
|
889
|
+
{layout === "full_screen" && onShowList && <SplitListShowButton onClick={() => {
|
|
890
|
+
carryEdit();
|
|
891
|
+
onShowList();
|
|
892
|
+
}}/>}
|
|
722
893
|
</>}
|
|
723
894
|
trailing={<>
|
|
724
895
|
{pluginActionsTop}
|
|
@@ -727,7 +898,9 @@ parentEntityIds,
|
|
|
727
898
|
path,
|
|
728
899
|
entityId,
|
|
729
900
|
values: formContext?.values ?? usedEntity?.values ?? {},
|
|
730
|
-
status
|
|
901
|
+
status,
|
|
902
|
+
dirty: Boolean(formContext?.formex?.dirty),
|
|
903
|
+
carryEdit
|
|
731
904
|
})}
|
|
732
905
|
</>}
|
|
733
906
|
/>
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { AdminCollection } from "@rebasepro/admin-types";
|
|
2
|
+
import type { Entity, Property } from "@rebasepro/types";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { Chip, cls, Typography } from "@rebasepro/ui";
|
|
5
|
+
|
|
6
|
+
import { useEntityDisplayValues } from "../hooks/useEntityDisplay";
|
|
7
|
+
import { PropertyPreview } from "../preview";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The roles whose property the read-only view takes out of its grid.
|
|
11
|
+
*
|
|
12
|
+
* A role rendered here and left in the grid prints the value twice, so the
|
|
13
|
+
* header's own rendering and this list have to agree — but the list is *not*
|
|
14
|
+
* simply "every role the header draws". Two are deliberately absent:
|
|
15
|
+
*
|
|
16
|
+
* `title`, because the heading above a record and a labelled field are not the
|
|
17
|
+
* same thing. `Order #` is a column with a name, a description and a format that
|
|
18
|
+
* someone reads the record to find; `ORD-2026-0043` sitting in the identity bar
|
|
19
|
+
* beside the breadcrumb is a label for the page. Removing the field because the
|
|
20
|
+
* page is named after it takes a value out of the record on the grounds that its
|
|
21
|
+
* text appears elsewhere, which is not a reason — a value in the database is
|
|
22
|
+
* shown.
|
|
23
|
+
*
|
|
24
|
+
* `date`, because the rail's record block already carries the timestamps, and a
|
|
25
|
+
* collection whose `date` role points at a business date — an order date, a due
|
|
26
|
+
* date — wants that field where it declared it.
|
|
27
|
+
*/
|
|
28
|
+
export const HEADER_DISPLAY_ROLES = ["subtitle", "image", "status", "tags"] as const;
|
|
29
|
+
|
|
30
|
+
export interface EntityDisplayHeaderProps<M extends Record<string, unknown>> {
|
|
31
|
+
entity: Entity<M>;
|
|
32
|
+
collection: AdminCollection<M>;
|
|
33
|
+
/**
|
|
34
|
+
* Render the title. Off when an identity bar above already shows it — which
|
|
35
|
+
* is every full-page use — and on inside a dialog that has no bar.
|
|
36
|
+
*/
|
|
37
|
+
showTitle?: boolean;
|
|
38
|
+
className?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* What the record *is*, above the fields that say what it holds.
|
|
43
|
+
*
|
|
44
|
+
* A record's picture, name, state and labels are the things you opened the page
|
|
45
|
+
* to see; on the plain grid they were fields like any other, so an order's
|
|
46
|
+
* status sat in the second cell of the first row at the same size and weight as
|
|
47
|
+
* its shipping cost, and its tags were a wrapped list of grey text.
|
|
48
|
+
*
|
|
49
|
+
* Every role here comes from `admin.display` and nothing is derived except the
|
|
50
|
+
* title, which was already derived for the identity bar. A collection that
|
|
51
|
+
* declares no display block therefore gets no header at all rather than a
|
|
52
|
+
* heuristic guess at which of its enums is a "status" — the panel does not know
|
|
53
|
+
* what these records are, and a wrong guess here is louder than no guess.
|
|
54
|
+
*
|
|
55
|
+
* A role filled by a *property* renders through {@link PropertyPreview}, so a
|
|
56
|
+
* status keeps its enum's colour and an image stays a storage thumbnail. A role
|
|
57
|
+
* filled by a resolver has no property behind it and renders as what it
|
|
58
|
+
* returned.
|
|
59
|
+
*/
|
|
60
|
+
export function EntityDisplayHeader<M extends Record<string, unknown>>({
|
|
61
|
+
entity,
|
|
62
|
+
collection,
|
|
63
|
+
showTitle = false,
|
|
64
|
+
className
|
|
65
|
+
}: EntityDisplayHeaderProps<M>) {
|
|
66
|
+
|
|
67
|
+
const display = useEntityDisplayValues<M>({
|
|
68
|
+
collection,
|
|
69
|
+
entity
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const title = showTitle ? asText(display.title.value) : undefined;
|
|
73
|
+
const subtitle = asText(display.subtitle.value);
|
|
74
|
+
const tags = asTags(display.tags.value);
|
|
75
|
+
|
|
76
|
+
const hasStatus = display.status.value !== undefined;
|
|
77
|
+
const hasImage = display.image.value !== undefined;
|
|
78
|
+
|
|
79
|
+
if (!title && !subtitle && !hasStatus && !hasImage && !tags.length) return null;
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className={cls("flex items-start gap-4 min-w-0", className)}>
|
|
83
|
+
|
|
84
|
+
{hasImage && (
|
|
85
|
+
<div className={"shrink-0 w-16 h-16 rounded-lg overflow-hidden flex items-center justify-center bg-surface-100 dark:bg-surface-800"}>
|
|
86
|
+
<RoleValue role={display.image} propertyFallback={
|
|
87
|
+
// A resolver's image is documented as a URL; a storage
|
|
88
|
+
// path only resolves when a property is behind it, which
|
|
89
|
+
// is what carries the storage source.
|
|
90
|
+
typeof display.image.value === "string"
|
|
91
|
+
? <img src={display.image.value}
|
|
92
|
+
alt={""}
|
|
93
|
+
className={"w-full h-full object-cover"}/>
|
|
94
|
+
: null
|
|
95
|
+
}/>
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
|
|
99
|
+
<div className={"flex flex-col gap-1.5 min-w-0 flex-1"}>
|
|
100
|
+
|
|
101
|
+
{title && (
|
|
102
|
+
<Typography variant={"h5"} className={"min-w-0 truncate m-0"}>
|
|
103
|
+
{title}
|
|
104
|
+
</Typography>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
{subtitle && (
|
|
108
|
+
<Typography variant={"body2"}
|
|
109
|
+
color={"secondary"}
|
|
110
|
+
className={"min-w-0 m-0"}>
|
|
111
|
+
{subtitle}
|
|
112
|
+
</Typography>
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
{(hasStatus || tags.length > 0) && (
|
|
116
|
+
<div className={"flex flex-wrap items-center gap-1.5"}>
|
|
117
|
+
{hasStatus && (
|
|
118
|
+
<RoleValue role={display.status} propertyFallback={
|
|
119
|
+
<Chip size={"small"}>{asText(display.status.value)}</Chip>
|
|
120
|
+
}/>
|
|
121
|
+
)}
|
|
122
|
+
{tags.map(tag => (
|
|
123
|
+
<Chip key={tag} size={"small"} outlined>{tag}</Chip>
|
|
124
|
+
))}
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* One resolved role, rendered richly when a property is behind it.
|
|
134
|
+
*
|
|
135
|
+
* `source` is set only when the value came from a property of this collection
|
|
136
|
+
* (see `useEntityDisplay`), and it carries what a rich rendering needs. Its
|
|
137
|
+
* absence is exactly the "this was computed" signal, and a computed value is a
|
|
138
|
+
* bare string with no property to describe it — hence the fallback.
|
|
139
|
+
*/
|
|
140
|
+
function RoleValue({
|
|
141
|
+
role,
|
|
142
|
+
propertyFallback
|
|
143
|
+
}: {
|
|
144
|
+
role: { value: unknown, source?: { key: string, property: Property | undefined, value: unknown } };
|
|
145
|
+
propertyFallback: React.ReactNode;
|
|
146
|
+
}) {
|
|
147
|
+
const source = role.source;
|
|
148
|
+
if (!source?.property) return <>{propertyFallback}</>;
|
|
149
|
+
return <PropertyPreview propertyKey={source.key}
|
|
150
|
+
value={source.value}
|
|
151
|
+
property={source.property}
|
|
152
|
+
hideLabel
|
|
153
|
+
size={"medium"}/>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A role's value as a line of text, or nothing when it holds nothing to read. */
|
|
157
|
+
function asText(value: unknown): string | undefined {
|
|
158
|
+
if (value === undefined || value === null) return undefined;
|
|
159
|
+
const text = String(value).trim();
|
|
160
|
+
return text.length ? text : undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The `tags` role, which takes a single string as shorthand for one tag. */
|
|
164
|
+
function asTags(value: unknown): string[] {
|
|
165
|
+
if (value === undefined || value === null) return [];
|
|
166
|
+
const list = Array.isArray(value) ? value : [value];
|
|
167
|
+
return list
|
|
168
|
+
.map(entry => asText(entry))
|
|
169
|
+
.filter((entry): entry is string => Boolean(entry));
|
|
170
|
+
}
|