@rebasepro/admin 0.13.0 → 0.13.1-canary.g394d868

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.
Files changed (137) hide show
  1. package/dist/{CollectionEditorDialog-CjXkWZ64.js → CollectionEditorDialog-D78xsXgL.js} +3 -3
  2. package/dist/{CollectionEditorDialog-CjXkWZ64.js.map → CollectionEditorDialog-D78xsXgL.js.map} +1 -1
  3. package/dist/{PropertyEditView-W8I3YV89.js → PropertyEditView-B88EC-1f.js} +2 -2
  4. package/dist/{PropertyEditView-W8I3YV89.js.map → PropertyEditView-B88EC-1f.js.map} +1 -1
  5. package/dist/{RouterCollectionsStudioView-BsLTNoqX.js → RouterCollectionsStudioView-B1Ws7h9l.js} +4 -4
  6. package/dist/{RouterCollectionsStudioView-BsLTNoqX.js.map → RouterCollectionsStudioView-B1Ws7h9l.js.map} +1 -1
  7. package/dist/collection_editor/serializable_types.d.ts +1 -0
  8. package/dist/collection_editor_ui.js +4 -4
  9. package/dist/components/AdditionalFieldValue.d.ts +25 -0
  10. package/dist/components/CollectionViewBinding/CollectionViewBinding.d.ts +16 -0
  11. package/dist/components/CollectionViewBinding/SlotValue.d.ts +63 -0
  12. package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +17 -4
  13. package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +12 -0
  14. package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +55 -5
  15. package/dist/components/DetailViewBinding.d.ts +15 -0
  16. package/dist/components/DrawerNavigationGroup.d.ts +9 -6
  17. package/dist/components/DrawerNavigationItem.d.ts +16 -6
  18. package/dist/components/EditViewBinding.d.ts +30 -1
  19. package/dist/components/EntityDisplayHeader.d.ts +53 -0
  20. package/dist/components/EntityIdentityBar.d.ts +43 -1
  21. package/dist/components/EntityViewBinding.d.ts +60 -6
  22. package/dist/components/RebaseLayout.d.ts +1 -1
  23. package/dist/components/RebaseShell.d.ts +1 -1
  24. package/dist/components/RelationSelector.d.ts +8 -1
  25. package/dist/components/UserSelector.d.ts +8 -1
  26. package/dist/components/app/Scaffold.d.ts +9 -3
  27. package/dist/components/index.d.ts +2 -0
  28. package/dist/{export-J79rMoNj.js → export-BZhdWhkB.js} +2 -2
  29. package/dist/{export-J79rMoNj.js.map → export-BZhdWhkB.js.map} +1 -1
  30. package/dist/form/components/FieldBlock.d.ts +58 -4
  31. package/dist/form/components/FormRail.d.ts +13 -0
  32. package/dist/form/components/FormSections.d.ts +15 -1
  33. package/dist/form/components/useRailVisible.d.ts +5 -4
  34. package/dist/form/form_utils.d.ts +38 -1
  35. package/dist/{history-D7L_JlLO.js → history-BPpt68CL.js} +4 -4
  36. package/dist/{history-D7L_JlLO.js.map → history-BPpt68CL.js.map} +1 -1
  37. package/dist/hooks/useEntityDisplay.d.ts +102 -0
  38. package/dist/hooks/useEntityDisplayTitle.d.ts +18 -9
  39. package/dist/{import-BvYl--U4.js → import-gRfemegK.js} +3 -3
  40. package/dist/{import-BvYl--U4.js.map → import-gRfemegK.js.map} +1 -1
  41. package/dist/index.d.ts +2 -2
  42. package/dist/index.js +152 -57
  43. package/dist/index.js.map +1 -1
  44. package/dist/preview/compact_text.d.ts +23 -0
  45. package/dist/preview/components/BooleanPreview.d.ts +3 -1
  46. package/dist/preview/property_previews/ArrayOfStringsPreview.d.ts +1 -1
  47. package/dist/preview/property_previews/ArrayOneOfPreview.d.ts +1 -1
  48. package/dist/preview/property_previews/ArrayPropertyPreview.d.ts +1 -1
  49. package/dist/preview/property_previews/MapPropertyPreview.d.ts +1 -1
  50. package/dist/preview/property_previews/StringPropertyPreview.d.ts +1 -1
  51. package/dist/routes/RebaseRoute.d.ts +1 -1
  52. package/dist/types/components/PropertyPreviewProps.d.ts +19 -0
  53. package/dist/util/number_format.d.ts +14 -0
  54. package/dist/util/previews.d.ts +9 -2
  55. package/dist/{util-GiwPgxnL.js → util-BHq3VOjH.js} +9080 -7861
  56. package/dist/util-BHq3VOjH.js.map +1 -0
  57. package/package.json +9 -9
  58. package/src/collection_editor/serializable_types.ts +1 -0
  59. package/src/components/AdditionalFieldValue.tsx +68 -0
  60. package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +7 -8
  61. package/src/components/CollectionTableBinding/PropertyTableCell.tsx +2 -2
  62. package/src/components/CollectionTableBinding/column_utils.tsx +15 -6
  63. package/src/components/CollectionTableBinding/internal/popup_field/PopupFormField.tsx +2 -2
  64. package/src/components/CollectionViewBinding/BoardCardBinding.tsx +26 -27
  65. package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +172 -112
  66. package/src/components/CollectionViewBinding/CollectionViewActions.tsx +1 -1
  67. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +38 -8
  68. package/src/components/CollectionViewBinding/EntityCardBinding.tsx +23 -36
  69. package/src/components/CollectionViewBinding/SlotValue.tsx +156 -0
  70. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +23 -7
  71. package/src/components/CollectionViewBinding/SplitListShowButton.tsx +28 -0
  72. package/src/components/CollectionViewBinding/SplitListView.tsx +33 -2
  73. package/src/components/CollectionViewBinding/useBoardDataController.tsx +12 -3
  74. package/src/components/CollectionViewBinding/usePreviewSlots.ts +177 -54
  75. package/src/components/DefaultDrawer.tsx +19 -23
  76. package/src/components/DetailViewBinding.tsx +87 -55
  77. package/src/components/DrawerNavigationGroup.tsx +16 -39
  78. package/src/components/DrawerNavigationItem.tsx +44 -16
  79. package/src/components/EditViewBinding.tsx +211 -41
  80. package/src/components/EntityDisplayHeader.tsx +170 -0
  81. package/src/components/EntityIdentityBar.tsx +149 -23
  82. package/src/components/EntityPreviewBinding.tsx +58 -18
  83. package/src/components/EntityViewBinding.tsx +342 -41
  84. package/src/components/InlineEntityPreview.tsx +10 -10
  85. package/src/components/RebaseLayout.tsx +2 -2
  86. package/src/components/RebaseShell.tsx +2 -2
  87. package/src/components/ReferenceTable/SelectionTableBinding.tsx +4 -2
  88. package/src/components/RelationSelector.tsx +15 -4
  89. package/src/components/SideDialogs.tsx +15 -3
  90. package/src/components/SidePanelBinding.tsx +122 -66
  91. package/src/components/UserSelector.tsx +15 -4
  92. package/src/components/app/Scaffold.tsx +112 -10
  93. package/src/components/index.ts +3 -0
  94. package/src/form/EntityForm.tsx +64 -52
  95. package/src/form/EntityFormBinding.tsx +24 -11
  96. package/src/form/PropertyFieldBinding.tsx +2 -2
  97. package/src/form/components/FieldBlock.tsx +80 -10
  98. package/src/form/components/FormRail.tsx +19 -2
  99. package/src/form/components/FormSections.tsx +32 -5
  100. package/src/form/components/StorageUploadProgress.tsx +9 -3
  101. package/src/form/components/useRailVisible.tsx +5 -4
  102. package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +2 -2
  103. package/src/form/field_bindings/BinaryFieldBinding.tsx +10 -5
  104. package/src/form/field_bindings/BlockFieldBinding.tsx +3 -3
  105. package/src/form/field_bindings/GeopointFieldBinding.tsx +23 -16
  106. package/src/form/field_bindings/MapFieldBinding.tsx +2 -2
  107. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +13 -7
  108. package/src/form/field_bindings/MultiSelectFieldBinding.tsx +7 -1
  109. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +7 -1
  110. package/src/form/field_bindings/ReferenceFieldBinding.tsx +1 -1
  111. package/src/form/field_bindings/RelationFieldBinding.tsx +5 -2
  112. package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -3
  113. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +2 -2
  114. package/src/form/field_bindings/UserSelectFieldBinding.tsx +4 -1
  115. package/src/form/field_bindings/VectorFieldBinding.tsx +6 -4
  116. package/src/form/form_utils.ts +78 -0
  117. package/src/hooks/navigation/useBuildCollectionRegistryController.tsx +29 -5
  118. package/src/hooks/useEntityDisplay.tsx +262 -0
  119. package/src/hooks/useEntityDisplayTitle.tsx +27 -38
  120. package/src/index.ts +9 -1
  121. package/src/preview/PropertyPreview.tsx +25 -5
  122. package/src/preview/compact_text.ts +68 -0
  123. package/src/preview/components/BooleanPreview.tsx +5 -2
  124. package/src/preview/components/StorageThumbnail.tsx +5 -2
  125. package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +6 -3
  126. package/src/preview/property_previews/ArrayOfStringsPreview.tsx +26 -1
  127. package/src/preview/property_previews/ArrayOneOfPreview.tsx +10 -1
  128. package/src/preview/property_previews/ArrayPropertyPreview.tsx +10 -1
  129. package/src/preview/property_previews/MapPropertyPreview.tsx +52 -1
  130. package/src/preview/property_previews/NumberPropertyPreview.tsx +9 -1
  131. package/src/preview/property_previews/StringPropertyPreview.tsx +8 -5
  132. package/src/routes/RebaseRoute.tsx +62 -4
  133. package/src/types/components/PropertyPreviewProps.tsx +21 -0
  134. package/src/util/number_format.ts +46 -0
  135. package/src/util/previews.ts +8 -55
  136. package/src/util/property_utils.tsx +6 -1
  137. 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,38 @@ export function EditViewBinding<M extends Record<string, unknown>>({
127
158
  useCache: false
128
159
  });
129
160
 
130
- const initialDirtyValues = entityId
131
- ? getEntityFromMemoryCache(props.path + "/" + entityId)
132
- : (props.defaultValues ?? getEntityFromMemoryCache(props.path + "#new"));
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
+ // A copy is keyed with the new records, not under the record it was copied
173
+ // from: it is going to be stored as its own row, and both the handoff and
174
+ // the local-changes backup have always keyed it that way from the writing
175
+ // side. Reading it under `path/id` meant a copy in progress was written to
176
+ // one key and looked for under another.
177
+ const carriesAsNew = props.copy || entityId === undefined;
178
+ const handoffKey = carriesAsNew ? props.path + "#new" : props.path + "/" + entityId;
179
+ const handoff = useRef<{ key: string, values: object | undefined } | null>(null);
180
+ if (handoff.current?.key !== handoffKey) {
181
+ handoff.current = {
182
+ key: handoffKey,
183
+ values: getEntityFromMemoryCache(handoffKey)
184
+ };
185
+ }
186
+ useEffect(() => {
187
+ removeEntityFromMemoryCache(handoffKey);
188
+ }, [handoffKey]);
189
+
190
+ const initialDirtyValues = carriesAsNew
191
+ ? (props.defaultValues ?? handoff.current.values)
192
+ : handoff.current.values;
133
193
 
134
194
  const { canEdit: canEditHook } = usePermissions();
135
195
 
@@ -186,12 +246,14 @@ export function EditViewBindingInner<M extends Record<string, unknown>>({
186
246
  onSaved,
187
247
  onTabChange,
188
248
  navigateBack,
249
+ onCloseRequest,
189
250
  entity,
190
251
  initialDirtyValues,
191
252
  dataLoading,
192
253
  layout = "side_panel",
193
254
  barActions,
194
255
  barActionsStart,
256
+ onShowList,
195
257
  status,
196
258
  setStatus,
197
259
  formProps,
@@ -405,8 +467,8 @@ parentEntityIds,
405
467
 
406
468
  const displayTitle = useEntityDisplayTitle({
407
469
  collection,
470
+ entity: usedEntity,
408
471
  values: (formContext?.values ?? usedEntity?.values) as Record<string, unknown> | undefined,
409
- entityId,
410
472
  status
411
473
  });
412
474
 
@@ -420,6 +482,35 @@ parentEntityIds,
420
482
  || (status === "existing" && !formex?.dirty)
421
483
  );
422
484
 
485
+ /**
486
+ * Hand this record's edit in progress to the layout about to replace this
487
+ * one — the split's "hide list", full screen's "show list", the side panel's
488
+ * "open full screen". None of them is a navigation *away* from the record:
489
+ * the same record is about to be shown by a different form, and what the
490
+ * user has typed belongs to the record, not to the pane it was typed in.
491
+ *
492
+ * Every control that changes layout has to call this, and none of them is
493
+ * blocked by the unsaved-changes prompt — the prompt asks about leaving, and
494
+ * these do not leave. What used to happen instead is that the edit reached
495
+ * the next layout only as its local-changes backup, which is the channel for
496
+ * a draft left behind by a closed tab: the record reopened *clean*, with a
497
+ * banner offering to apply changes the user had never walked away from.
498
+ */
499
+ const carryEdit = useCallback(() => {
500
+ const carried = formex
501
+ ? getEditHandoffValues<M>({
502
+ status,
503
+ dirty: formex.dirty,
504
+ values: formex.values,
505
+ touched: formex.touched,
506
+ storedValues: usedEntity?.values
507
+ })
508
+ : undefined;
509
+ if (!carried) return;
510
+ const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
511
+ saveEntityToMemoryCache(key, carried);
512
+ }, [formex, status, path, entityId, usedEntity]);
513
+
423
514
  const [inspectorTab, setInspectorTab] = useState<InspectorTab | null>(null);
424
515
  // A save adds a revision, and the inspector can be open while it happens:
425
516
  // Save lives in the identity bar, above the panel. Counting saves gives the
@@ -429,7 +520,26 @@ parentEntityIds,
429
520
  /* ---- record actions, in the bar's overflow menu ---------------------- */
430
521
 
431
522
  const sideDialogContext = useSideDialogContext();
432
- const canCloseAfterSave = layout === "side_panel" || layout === "dialog";
523
+
524
+ /**
525
+ * Whether the save in flight was asked to dismiss the record afterwards.
526
+ *
527
+ * A ref, and for the reason {@link SideDialogs} keeps its own as one: the
528
+ * flag is raised and the form submitted in a single click handler, and the
529
+ * `onSaved` that has to act on it was captured before React could re-render.
530
+ * As state it read one save late — the first "save and close" saved and
531
+ * stayed open, and the *next* save closed the record.
532
+ *
533
+ * The side panel and the dialog use the side-dialog controller's flag,
534
+ * which their own close path consumes; this one covers the layouts that
535
+ * close by calling {@link onCloseRequest} instead.
536
+ */
537
+ const pendingCloseRef = useRef(false);
538
+
539
+ // The split can close after a save the same way the overlays do — through
540
+ // the callback its layout supplies. Without one there is nothing to close.
541
+ const canCloseAfterSave = layout === "side_panel" || layout === "dialog"
542
+ || (layout === "split" && Boolean(onCloseRequest));
433
543
 
434
544
  const recordActions = useRecordActions({
435
545
  collection,
@@ -524,35 +634,39 @@ parentEntityIds,
524
634
  const FormViewBuilder = formViewConfig?.Builder ? resolveComponentRef<EntityCustomViewParams>(formViewConfig.Builder as ComponentRef<EntityCustomViewParams>) : null;
525
635
  const formViewIncludeActions = formViewConfig?.includeActions !== false;
526
636
 
527
- const entityReadOnlyView = !canEdit && entity ? <div
528
- className={cls("flex-1 flex flex-row w-full overflow-y-auto justify-center", (canEdit || !mainViewVisible || selectedSecondaryForm) ? "hidden" : "")}>
529
- <div
530
- className={cls("relative flex flex-col max-w-4xl lg:max-w-3xl xl:max-w-4xl 2xl:max-w-6xl w-full h-fit")}>
531
- <Typography className={"mt-16 mb-8 mx-8"} variant={"h4"}>
532
- {collection.singularName ?? collection.name}
533
- </Typography>
534
- {FormViewBuilder && readOnlyFormContext ? (
535
- <ErrorBoundary>
536
- <Suspense fallback={<CircularProgressCenter />}>
537
- <FormViewBuilder
538
- collection={collection}
539
- parentCollectionSlugs={parentCollectionSlugs} parentEntityIds={parentEntityIds}
540
- entity={usedEntity}
541
- modifiedValues={usedEntity?.values}
542
- formContext={readOnlyFormContext as FormContext<Record<string, unknown>>}
543
- />
544
- </Suspense>
545
- </ErrorBoundary>
546
- ) : (
547
- <EntityViewBinding
548
- className={"px-8 h-full overflow-auto"}
549
- entity={entity}
550
- path={path}
551
- collection={collection} />
552
- )}
553
- <div className="h-16" />
554
- </div>
555
- </div> : null;
637
+ // Without edit permission the record is rendered read-only — the same
638
+ // sections, spans and rail as the form, so losing the permission changes the
639
+ // controls and nothing else. The `h4` that used to head this repeated the
640
+ // collection name the identity bar above it already carries.
641
+ const entityReadOnlyView = !canEdit && entity ? (
642
+ FormViewBuilder && readOnlyFormContext
643
+ ? <div className={cls(
644
+ "flex-1 flex flex-row w-full overflow-y-auto justify-center",
645
+ (canEdit || !mainViewVisible || selectedSecondaryForm) ? "hidden" : ""
646
+ )}>
647
+ <div className={"w-full max-w-3xl 2xl:max-w-4xl flex flex-col pt-6 pb-16 px-5 sm:px-8"}>
648
+ <ErrorBoundary>
649
+ <Suspense fallback={<CircularProgressCenter />}>
650
+ <FormViewBuilder
651
+ collection={collection}
652
+ parentCollectionSlugs={parentCollectionSlugs} parentEntityIds={parentEntityIds}
653
+ entity={usedEntity}
654
+ modifiedValues={usedEntity?.values}
655
+ formContext={readOnlyFormContext as FormContext<Record<string, unknown>>}
656
+ />
657
+ </Suspense>
658
+ </ErrorBoundary>
659
+ </div>
660
+ </div>
661
+ : <EntityViewBinding
662
+ className={cls((canEdit || !mainViewVisible || selectedSecondaryForm) ? "hidden" : "")}
663
+ entity={entity}
664
+ path={path}
665
+ collection={collection}
666
+ asPage
667
+ openEntityMode={layout}
668
+ formContext={readOnlyFormContext}/>
669
+ ) : null;
556
670
 
557
671
  const entityView = FormViewBuilder ? (
558
672
  // formView.Builder replaces the default form
@@ -609,8 +723,32 @@ parentEntityIds,
609
723
  ...params,
610
724
  selectedTab: MAIN_TAB_VALUE === activeTab ? undefined : activeTab
611
725
  };
726
+ // Two caller-supplied callbacks for one save, and either may
727
+ // navigate — the layouts' `onSaved` all do. Where both do, the
728
+ // second one's destination is what survives (docs/bug-classes.md
729
+ // #28), so `formProps.onSaved` is deliberately last: it belongs
730
+ // to whoever embedded this view, which outranks the layout.
612
731
  onSaved?.(res);
613
732
  formProps?.onSaved?.(res);
733
+
734
+ // After `onSaved`, never instead of it: the layout's own handler
735
+ // is what lowers its dirty flag, and closing is a navigation the
736
+ // unsaved-changes blocker would otherwise stop on values that
737
+ // are already stored.
738
+ //
739
+ // And after this handler returns, not inside it. The layouts
740
+ // that close this way close by navigating, and they have just
741
+ // navigated themselves — the split leaves `…/edit` for the
742
+ // record's own URL. Deferring keeps the close unambiguously
743
+ // last, which is what decides it: of two navigations raised for
744
+ // one action the final one survives, measured in
745
+ // `router_two_navigations_one_handler.test.tsx`. It also lets
746
+ // the state those handlers just set settle before the blocker
747
+ // reads it. See docs/bug-classes.md #28.
748
+ if (pendingCloseRef.current) {
749
+ pendingCloseRef.current = false;
750
+ setTimeout(() => onCloseRequest?.(), 0);
751
+ }
614
752
  }}
615
753
  Builder={resolveComponentRef(selectedSecondaryForm?.Builder as ComponentRef<EntityCustomViewParams<M>> | undefined) as React.ComponentType<EntityCustomViewParams<M>> | undefined}
616
754
  />
@@ -673,6 +811,7 @@ parentEntityIds,
673
811
  <IconButton
674
812
  size="small"
675
813
  onClick={() => {
814
+ carryEdit();
676
815
  const editSuffix = collection.defaultEntityAction === "view" ? "/edit" : "";
677
816
  const entityUrl = urlController.buildUrlCollectionPath(`${path}/${entityId}${editSuffix}`);
678
817
  navigate(`${entityUrl}#full`);
@@ -687,6 +826,9 @@ parentEntityIds,
687
826
 
688
827
  <EntityIdentityBar
689
828
  collection={collection as AdminCollection}
829
+ collectionUrl={layout === "full_screen" || layout === "split"
830
+ ? withViewMode(urlController.buildUrlCollectionPath(path))
831
+ : undefined}
690
832
  title={displayTitle}
691
833
  entityId={status === "existing" ? entityId : undefined}
692
834
  status={status}
@@ -696,29 +838,55 @@ parentEntityIds,
696
838
  saveDisabled={!canEdit || saveDisabled}
697
839
  // Full screen replaces the collection entirely, leaving browser Back
698
840
  // 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
- onBack={layout === "full_screen"
841
+ // for that reason; the edit view is reached the same way. Where the
842
+ // list can be unfolded back beside the record, that chevron reaches
843
+ // the same collection and the arrow beside it is noise.
844
+ onBack={layout === "full_screen" && !onShowList
701
845
  ? () => navigate(withViewMode(urlController.buildUrlCollectionPath(path)))
702
846
  : undefined}
703
847
  onSave={canEdit && formContext ? () => {
704
848
  sideDialogContext.setPendingClose?.(false);
849
+ pendingCloseRef.current = false;
705
850
  formContext.submit();
706
851
  } : undefined}
707
852
  onSaveAndClose={canEdit && formContext && canCloseAfterSave ? () => {
853
+ // Lowered again once the submit settles. A submit the form
854
+ // rejects never reaches `onSaved`, so nothing would consume the
855
+ // flag and the *next* save — a keyboard ⌘S, say — would close
856
+ // the panel out from under an edit nobody asked to finish. A
857
+ // save that succeeds has already closed by the time this runs.
858
+ if (layout === "split") {
859
+ pendingCloseRef.current = true;
860
+ Promise.resolve(formContext.submit())
861
+ .finally(() => { pendingCloseRef.current = false; });
862
+ return;
863
+ }
708
864
  sideDialogContext.setPendingClose?.(true);
709
- formContext.submit();
865
+ Promise.resolve(formContext.submit())
866
+ .finally(() => sideDialogContext.setPendingClose?.(false));
710
867
  } : undefined}
868
+ // Welded to Save in the split, its own button in the overlays.
869
+ saveAndClosePlacement={layout === "split" ? "menu" : "button"}
870
+ onClose={onCloseRequest}
711
871
  onDiscard={canEdit && formContext ? () => formContext.formex.resetForm() : undefined}
712
872
  onInspect={includeJsonView ? () => setInspectorTab("json") : undefined}
713
873
  onViewHistory={includeHistoryView ? () => setInspectorTab("history") : undefined}
874
+ externalLink={usedEntity
875
+ ? customizationController?.entityLinkBuilder?.({ entity: usedEntity })
876
+ : undefined}
714
877
  recordActions={recordActionItems}
715
878
  pluginActions={formPluginActions}
716
879
  leading={<>
717
880
  {barActionsStart}
718
881
  {/* Split view: closing the list is opening this record
719
882
  full screen, and the control for it belongs at this
720
- bar's leading edge. */}
721
- {layout === "split" && <SplitListCloseButton/>}
883
+ bar's leading edge. Full screen carries its mirror.
884
+ Both change layout, so both hand the edit over. */}
885
+ {layout === "split" && <SplitListCloseButton onBeforeHide={carryEdit}/>}
886
+ {layout === "full_screen" && onShowList && <SplitListShowButton onClick={() => {
887
+ carryEdit();
888
+ onShowList();
889
+ }}/>}
722
890
  </>}
723
891
  trailing={<>
724
892
  {pluginActionsTop}
@@ -727,7 +895,9 @@ parentEntityIds,
727
895
  path,
728
896
  entityId,
729
897
  values: formContext?.values ?? usedEntity?.values ?? {},
730
- status
898
+ status,
899
+ dirty: Boolean(formContext?.formex?.dirty),
900
+ carryEdit
731
901
  })}
732
902
  </>}
733
903
  />
@@ -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
+ }