@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
@@ -9,7 +9,7 @@ import { EditViewBinding } from "./EditViewBinding";
9
9
  import { DetailViewBinding } from "./DetailViewBinding";
10
10
  import { useSideDialogContext } from "./SideDialogs";
11
11
  import { useNavigate } from "react-router";
12
- import { saveEntityToMemoryCache, useComponentOverride } from "@rebasepro/app";
12
+ import { useComponentOverride } from "@rebasepro/app";
13
13
  import { useCollectionRegistryController } from "../hooks/navigation/contexts/CollectionRegistryContext";
14
14
  import { useSidePanel } from "../hooks/useSidePanel";
15
15
  import { useUrlController } from "../hooks/navigation/contexts/UrlContext";
@@ -49,21 +49,60 @@ export function SidePanelBinding(props: SidePanelBindingProps) {
49
49
  const sideDialogsController = useSideDialogContext();
50
50
  const urlController = useUrlController();
51
51
 
52
+ /**
53
+ * The X in the identity bar.
54
+ *
55
+ * Unforced, so it stops at the same unsaved-changes prompt that Escape and
56
+ * a click outside already do. It used to clear `blocked` and force the
57
+ * close, which made the one control actually labelled "close" the only way
58
+ * out of the panel that dropped an edit without asking.
59
+ *
60
+ * `props.onClose` is not called here: the panel carries the same callback
61
+ * (see `propsToSidePanel`) and {@link SideDialogs} fires it once the panel
62
+ * really closes — which is after the prompt, not before it.
63
+ */
52
64
  const onClose = () => {
53
- if (props.onClose) {
54
- props.onClose();
55
- }
65
+ close();
66
+ }
56
67
 
68
+ /**
69
+ * Closing because the record was just saved, which is the one case that has
70
+ * to force. `blocked` is driven by an effect on the form's dirty flag and is
71
+ * still true for a tick after a save; prompting about unsaved changes here
72
+ * would be asking about values that are already on the server.
73
+ */
74
+ const closeAfterSave = () => {
57
75
  setBlocked(false);
58
76
  close(true);
59
77
  }
60
78
 
79
+ /**
80
+ * The panel's response to a save, and the one place it decides where the
81
+ * panel goes next.
82
+ *
83
+ * Exactly **one** panel navigation is raised here. These branches used to
84
+ * run independently — a `replace` onto the saved record's address *and* a
85
+ * close — and because the last navigation is the one that survives, which
86
+ * of them the user got was settled by statement order rather than by
87
+ * intent. Closing wins by construction now: moving a panel to a new address
88
+ * it is about to leave is work whose only effect is to fight the close.
89
+ *
90
+ * `props.onUpdate` runs last for the same reason. It belongs to whoever
91
+ * opened the panel and it is free to navigate — the reference picker's
92
+ * selects the record and closes the picker — so the opener's intent should
93
+ * be the final word, not something this panel overwrites on its way out.
94
+ */
61
95
  const onUpdate = (params: OnUpdateParams) => {
62
- if (props.onUpdate) {
63
- props.onUpdate(params);
64
- }
65
96
  setEditInDialog(false);
66
- if (params.status !== "existing") {
97
+
98
+ // Either "save and close" was clicked, or the opener asked for it when
99
+ // it opened the panel.
100
+ const closeRequested = sideDialogsController.pendingClose || props.closeOnSave;
101
+
102
+ if (closeRequested) {
103
+ sideDialogsController.setPendingClose(false);
104
+ closeAfterSave();
105
+ } else if (params.status !== "existing") {
67
106
  // A newly created entity replaces the panel with its own address,
68
107
  // which already leaves the edit view behind.
69
108
  sidePanelController.replace({
@@ -77,11 +116,7 @@ export function SidePanelBinding(props: SidePanelBindingProps) {
77
116
  closeEditView();
78
117
  }
79
118
 
80
- if (sideDialogsController.pendingClose) {
81
- sideDialogsController.setPendingClose(false);
82
- onClose();
83
- }
84
-
119
+ props.onUpdate?.(params);
85
120
  }
86
121
 
87
122
  const parentCollectionSlugs = useMemo(() => {
@@ -181,6 +216,43 @@ entityId }
181
216
  return <div className={"w-full"}/>;
182
217
  }
183
218
 
219
+ const closeButton = (
220
+ <IconButton
221
+ className="self-center"
222
+ size={"small"}
223
+ onClick={onClose}>
224
+ <XIcon/>
225
+ </IconButton>
226
+ );
227
+
228
+ /**
229
+ * Where the panel's own two controls sit, which is not the same question in
230
+ * the two layouts that have them.
231
+ *
232
+ * A **side panel** slides in over the list from the right, and its leading
233
+ * edge is the seam between the two. Close belongs on that seam — it points
234
+ * back at the thing still visible behind it — and it is where the eye
235
+ * already is, because the breadcrumb starts there.
236
+ *
237
+ * A **dialog** has no seam and nothing behind it to point at. It is a box
238
+ * centred on a dimmed screen, and every box like it on every platform is
239
+ * dismissed from its top-right corner. Close was sitting at the far left
240
+ * of the bar, a full dialog's width from the expand button it belongs
241
+ * beside, in the one corner that means "back" in a layout with no back.
242
+ *
243
+ * So in a dialog the two travel together at the trailing edge, expand then
244
+ * close: the window controls, after the record's actions and its overflow
245
+ * menu, in the corner a dialog is closed from.
246
+ */
247
+ const barActionsStart = isDialogMode ? undefined : closeButton;
248
+
249
+ const windowControls = (expandButton: React.ReactNode) => (
250
+ <div className="flex gap-1 items-center">
251
+ {expandButton}
252
+ {isDialogMode && closeButton}
253
+ </div>
254
+ );
255
+
184
256
  return (
185
257
  <>
186
258
  <ErrorBoundary>
@@ -193,31 +265,20 @@ entityId }
193
265
  parentCollectionSlugs={parentCollectionSlugs}
194
266
  parentEntityIds={parentEntityIds}
195
267
  onEditClick={openEditView}
196
- barActionsStart={
197
- // At the bar's leading edge, where the breadcrumb
198
- // starts: the corner of the panel it closes.
199
- <IconButton
268
+ barActionsStart={barActionsStart}
269
+ barActions={() => windowControls(
270
+ allowFullScreen && <IconButton
200
271
  className="self-center"
201
272
  size={"small"}
202
- onClick={onClose}>
203
- <XIcon/>
204
- </IconButton>}
205
- barActions={({
206
- status,
207
- values
208
- }) => <div className="flex gap-1">
209
- {allowFullScreen && <IconButton
210
- className="self-center"
211
- size={"small"}
212
- onClick={() => {
213
- if (entityId) {
214
- const fullScreenUrl = urlController.buildUrlCollectionPath(`${path}/${entityId}`);
215
- navigate(fullScreenUrl, { state: null });
216
- }
217
- }}>
218
- <Maximize2Icon/>
219
- </IconButton>}
220
- </div>}
273
+ onClick={() => {
274
+ if (entityId) {
275
+ const fullScreenUrl = urlController.buildUrlCollectionPath(`${path}/${entityId}`);
276
+ navigate(fullScreenUrl, { state: null });
277
+ }
278
+ }}>
279
+ <Maximize2Icon/>
280
+ </IconButton>
281
+ )}
221
282
  onTabChange={({
222
283
  entityId: tabEntityId,
223
284
  selectedTab,
@@ -245,38 +306,33 @@ entityId }
245
306
  onValuesModified={onValuesModified}
246
307
  onSaved={onUpdate}
247
308
  navigateBack={closeEditView}
248
- barActionsStart={
249
- // At the bar's leading edge, where the breadcrumb
250
- // starts: the corner of the panel it closes.
251
- <IconButton
309
+ barActionsStart={barActionsStart}
310
+ barActions={({ carryEdit }) => windowControls(
311
+ allowFullScreen && <IconButton
252
312
  className="self-center"
253
313
  size={"small"}
254
- onClick={onClose}>
255
- <XIcon/>
256
- </IconButton>}
257
- barActions={({
258
- status,
259
- values
260
- }) => <div className="flex gap-1">
261
- {allowFullScreen && <IconButton
262
- className="self-center"
263
- size={"small"}
264
- onClick={() => {
265
- const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
266
- saveEntityToMemoryCache(key, values);
267
- setBlocked(false);
268
- setBlockedNavigationMessage(undefined);
269
- if (entityId) {
270
- const fullScreenUrl = urlController.buildUrlCollectionPath(`${path}/${entityId}`);
271
- navigate(fullScreenUrl, { state: null });
272
- } else {
273
- const fullScreenUrl = urlController.buildUrlCollectionPath(path);
274
- navigate(fullScreenUrl + "#new", { state: null });
275
- }
276
- }}>
277
- <Maximize2Icon/>
278
- </IconButton>}
279
- </div>}
314
+ onClick={() => {
315
+ // The form's own handoff, rather than a second
316
+ // copy of the rules for it here: only an edit in
317
+ // progress is worth carrying (a clean record
318
+ // stashed anyway arrived announcing unsaved
319
+ // changes to someone who had only looked at it),
320
+ // and only the fields that were edited (carrying
321
+ // the whole record marked every field touched).
322
+ carryEdit?.();
323
+ setBlocked(false);
324
+ setBlockedNavigationMessage(undefined);
325
+ if (entityId) {
326
+ const fullScreenUrl = urlController.buildUrlCollectionPath(`${path}/${entityId}`);
327
+ navigate(fullScreenUrl, { state: null });
328
+ } else {
329
+ const fullScreenUrl = urlController.buildUrlCollectionPath(path);
330
+ navigate(fullScreenUrl + "#new", { state: null });
331
+ }
332
+ }}>
333
+ <Maximize2Icon/>
334
+ </IconButton>
335
+ )}
280
336
  onTabChange={({
281
337
  entityId: tabEntityId,
282
338
  selectedTab,
@@ -128,7 +128,14 @@ export interface UserSelectorProps {
128
128
  value?: string | null;
129
129
  onValueChange?: (userId: string | null) => void;
130
130
  placeholder?: React.ReactNode;
131
- size?: "small" | "medium";
131
+ /**
132
+ * `large` is the form's size and is on the shared 28/32/40/48 control
133
+ * scale, so a user picker lines up with the text field beside it.
134
+ *
135
+ * `small` and `medium` predate that scale (42 and 56) and are kept as they
136
+ * are because the table cells and the filter row are built around them.
137
+ */
138
+ size?: "small" | "medium" | "large";
132
139
  disabled?: boolean;
133
140
  invisible?: boolean;
134
141
  clearable?: boolean;
@@ -317,9 +324,13 @@ export const UserSelector = React.forwardRef<
317
324
  className={cls(
318
325
  {
319
326
  "min-h-[42px] py-1 px-2": size === "small",
320
- "min-h-[56px] py-2 px-4": size === "medium"
327
+ "min-h-[56px] py-2 px-4": size === "medium",
328
+ "min-h-[48px] py-1 px-4": size === "large"
321
329
  },
322
- "w-full select-none rounded-md text-sm relative flex items-center",
330
+ // `rounded-lg` like every other field box —
331
+ // `rounded-md` made this the one control in a
332
+ // row with a different corner.
333
+ "w-full select-none rounded-lg text-sm relative flex items-center",
323
334
  invisible ? fieldBackgroundInvisibleMixin : fieldBackgroundMixin,
324
335
  disabled ? fieldBackgroundDisabledMixin : fieldBackgroundHoverMixin,
325
336
  className
@@ -345,7 +356,7 @@ export const UserSelector = React.forwardRef<
345
356
  </IconButton>
346
357
  )}
347
358
  <ChevronDownIcon
348
- size={size === "medium" ? iconSize.medium : iconSize.small}
359
+ size={size === "small" ? iconSize.small : iconSize.medium}
349
360
  className={cls("transition", isPopoverOpen ? "rotate-180" : "")}
350
361
  />
351
362
  </div>
@@ -4,17 +4,48 @@ import { ChevronLeftIcon, ErrorBoundary, MenuIcon } from "@rebasepro/ui";
4
4
  import { deepEqual as equal } from "fast-equals"
5
5
 
6
6
  import { useLargeLayout, useAdminModeController, useTranslation } from "@rebasepro/app";
7
+ import { useUrlController } from "../../hooks/navigation/contexts/UrlContext";
7
8
  import { AppContext } from "./useApp";
8
9
 
9
10
  export const DRAWER_WIDTH = 280;
10
11
 
12
+ /**
13
+ * Namespaced by base path: two admins served from one origin are two different
14
+ * navigations, and a single key would have them overwrite each other's drawer.
15
+ */
16
+ export function drawerOpenStorageKey(basePath: string | undefined) {
17
+ return `rebase-drawer-open:${basePath || "/"}`;
18
+ }
19
+
20
+ /** `null` when the user has never toggled the drawer, or storage is unavailable. */
21
+ function readStoredDrawerOpen(key: string): boolean | null {
22
+ if (typeof window === "undefined") return null;
23
+ try {
24
+ const stored = window.localStorage.getItem(key);
25
+ return stored === null ? null : stored === "true";
26
+ } catch (e) {
27
+ // Storage can be blocked: private mode, sandboxed iframe, cookie policy.
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function writeStoredDrawerOpen(key: string, open: boolean) {
33
+ if (typeof window === "undefined") return;
34
+ try {
35
+ window.localStorage.setItem(key, String(open));
36
+ } catch (e) {
37
+ // Same as above: a drawer that forgets is better than a crash.
38
+ }
39
+ }
40
+
11
41
  /**
12
42
  * @group Core
13
43
  */
14
44
  export interface ScaffoldProps {
15
45
 
16
46
  /**
17
- * Open the drawer on hover
47
+ * Expand the collapsed drawer while the pointer is over it. On by default —
48
+ * pass `false` to keep the rail a rail until the user clicks the toggle.
18
49
  */
19
50
  autoOpenDrawer?: boolean;
20
51
 
@@ -22,8 +53,8 @@ export interface ScaffoldProps {
22
53
  * Start with the drawer expanded rather than collapsed to icons.
23
54
  *
24
55
  * Distinct from {@link autoOpenDrawer}, which is a hover behaviour: this one
25
- * only seeds the initial state, so the user's own toggle still wins for the
26
- * rest of the session.
56
+ * only seeds the very first visit. Once the user toggles the drawer, that
57
+ * choice is stored in `localStorage` and wins over this prop on every load.
27
58
  *
28
59
  * Ignored on small layouts, where an expanded drawer covers the content it is
29
60
  * meant to navigate.
@@ -62,7 +93,7 @@ export const Scaffold = React.memo<PropsWithChildren<ScaffoldProps>>(
62
93
 
63
94
  const {
64
95
  children,
65
- autoOpenDrawer,
96
+ autoOpenDrawer = true,
66
97
  defaultDrawerOpen = false,
67
98
  logo,
68
99
  className,
@@ -83,25 +114,96 @@ export const Scaffold = React.memo<PropsWithChildren<ScaffoldProps>>(
83
114
  const includeDrawer = drawerChildren.length > 0;
84
115
  const largeLayout = useLargeLayout();
85
116
 
117
+ const storageKey = drawerOpenStorageKey(useUrlController().basePath);
118
+
86
119
  // Seeded once, deliberately: a `useEffect` syncing this to the prop would
87
120
  // re-expand the drawer under a user who had just collapsed it.
88
- const [drawerOpen, setDrawerOpen] = React.useState(defaultDrawerOpen && largeLayout);
121
+ const [drawerOpen, setDrawerOpenState] = React.useState(defaultDrawerOpen && largeLayout);
89
122
  const [onHover, setOnHover] = React.useState(false);
90
123
 
91
- const setOnHoverTrue = useCallback(() => setOnHover(true), []);
124
+ // The stored choice is a client-only fact, so it is applied after the first
125
+ // render rather than seeded into it: reading storage while rendering would
126
+ // make the client disagree with server-rendered HTML. A layout effect runs
127
+ // before paint, so the drawer still arrives in its remembered state.
128
+ // `defaultDrawerOpen` seeds the very first visit and is ignored after that.
129
+ React.useLayoutEffect(() => {
130
+ const stored = readStoredDrawerOpen(storageKey);
131
+ if (stored === null) return;
132
+ setDrawerOpenState(stored && largeLayout);
133
+ // Deliberately not re-run on `largeLayout`: crossing the breakpoint is
134
+ // the effect below, and re-running here would undo a user's toggle.
135
+ // eslint-disable-next-line react-hooks/exhaustive-deps
136
+ }, [storageKey]);
137
+
138
+ // One piece of state drives two different things: the expanded rail on
139
+ // large layouts, and the modal sheet on small ones. Carrying an expanded
140
+ // rail across the breakpoint would drop that sheet, overlay and all, over
141
+ // the content — so the crossing resets it, and widening again restores
142
+ // whatever the user last chose.
143
+ const wasLargeLayout = React.useRef(largeLayout);
144
+ React.useEffect(() => {
145
+ if (wasLargeLayout.current === largeLayout) return;
146
+ wasLargeLayout.current = largeLayout;
147
+ setDrawerOpenState(largeLayout ? (readStoredDrawerOpen(storageKey) ?? defaultDrawerOpen) : false);
148
+ }, [largeLayout, defaultDrawerOpen, storageKey]);
149
+
150
+ const setDrawerOpen = useCallback((open: boolean) => {
151
+ setDrawerOpenState(open);
152
+ // Small layouts render the drawer as a modal sheet. Remembering that
153
+ // one would greet the next load with an overlay over the content.
154
+ if (!largeLayout) return;
155
+ writeStoredDrawerOpen(storageKey, open);
156
+ }, [largeLayout, storageKey]);
157
+
158
+ // The pointer has left, but a popover the drawer opened is still up, so the
159
+ // collapse is owed rather than cancelled. See `setOnHoverFalse`.
160
+ const collapseWhenPopoverCloses = React.useRef(false);
161
+
162
+ const setOnHoverTrue = useCallback(() => {
163
+ // Hover expansion is the default; `autoOpenDrawer={false}` opts out for
164
+ // admins that want the rail to stay a rail until the toggle is clicked.
165
+ if (!autoOpenDrawer) return;
166
+ collapseWhenPopoverCloses.current = false;
167
+ setOnHover(true);
168
+ }, [autoOpenDrawer]);
92
169
  const setOnHoverFalse = useCallback(() => {
93
- // Don't collapse the drawer while a popover/dropdown is open
94
- if (document.querySelector("[data-radix-popper-content-wrapper]")) return;
170
+ // Don't collapse the drawer out from under an open popover/dropdown —
171
+ // its content is portalled outside the drawer, so reaching for it reads
172
+ // as a mouseleave. The collapse is owed until that popover closes.
173
+ if (document.querySelector("[data-radix-popper-content-wrapper]")) {
174
+ collapseWhenPopoverCloses.current = true;
175
+ return;
176
+ }
177
+ collapseWhenPopoverCloses.current = false;
95
178
  setOnHover(false);
96
179
  }, []);
97
180
 
181
+ // Nothing fires a second mouseleave when the popover finally closes, so the
182
+ // owed collapse has to be noticed rather than waited for. Only mounted
183
+ // while the drawer is actually floating open.
184
+ React.useEffect(() => {
185
+ if (!onHover) return;
186
+ const collapseIfOwed = () => {
187
+ if (!collapseWhenPopoverCloses.current) return;
188
+ if (document.querySelector("[data-radix-popper-content-wrapper]")) return;
189
+ collapseWhenPopoverCloses.current = false;
190
+ setOnHover(false);
191
+ };
192
+ const observer = new MutationObserver(collapseIfOwed);
193
+ observer.observe(document.body, {
194
+ childList: true,
195
+ subtree: true
196
+ });
197
+ return () => observer.disconnect();
198
+ }, [onHover]);
199
+
98
200
  const handleDrawerOpen = useCallback(() => {
99
201
  setDrawerOpen(true);
100
- }, []);
202
+ }, [setDrawerOpen]);
101
203
 
102
204
  const handleDrawerClose = useCallback(() => {
103
205
  setDrawerOpen(false);
104
- }, []);
206
+ }, [setDrawerOpen]);
105
207
 
106
208
  const computedDrawerOpen: boolean = drawerOpen;
107
209
  const computedDrawerHovered = Boolean(largeLayout && onHover);
@@ -1,6 +1,9 @@
1
1
  export type { EntityViewBindingProps } from "./EntityViewBinding";
2
2
  export { EntityViewBinding } from "./EntityViewBinding";
3
3
 
4
+ export type { EntityDisplayHeaderProps } from "./EntityDisplayHeader";
5
+ export { EntityDisplayHeader, HEADER_DISPLAY_ROLES } from "./EntityDisplayHeader";
6
+
4
7
  export * from "./DetailViewBinding";
5
8
 
6
9
  export type { SelectionProps } from "./ReferenceTable/SelectionTableBinding";
@@ -9,18 +9,18 @@ import { deepEqual as equal } from "fast-equals";
9
9
  import { ErrorBoundary } from "@rebasepro/ui";
10
10
  import { AlignLeftIcon, useDebouncedCallback } from "@rebasepro/ui";
11
11
  import { getDefaultValuesFor } from "@rebasepro/common";
12
- import { isReadOnly } from "@rebasepro/app";
12
+ import { isDisabled, isReadOnly } from "@rebasepro/app";
13
13
 
14
14
  import { getFormFieldKeys, resolveFormLayout } from "@rebasepro/app";
15
15
  import type { ResolvedFormField } from "@rebasepro/app";
16
- import { Alert, Button, cls, Dialog, DialogActions, DialogContent, DialogTitle, iconSize, paperMixin, Typography } from "@rebasepro/ui";
16
+ import { Alert, Button, cls, defaultBorderMixin, Dialog, DialogActions, DialogContent, DialogTitle, paperMixin, Typography } from "@rebasepro/ui";
17
17
  import { Formex, FormexController, useCreateFormex } from "@rebasepro/forms";
18
18
 
19
- import { FieldBlock, isSelfLabellingProperty } from "./components/FieldBlock";
20
- import { FormRail } from "./components/FormRail";
19
+ import { FieldBlock, isSelfLabellingProperty, LABEL_ICON_SIZE, spanClass } from "./components/FieldBlock";
20
+ import { AdditionalFieldValue } from "../components/AdditionalFieldValue";
21
+ import { FormRail, RecordMeta } from "./components/FormRail";
21
22
  import { FormSections } from "./components/FormSections";
22
23
  import { useRailVisible } from "./components/useRailVisible";
23
- import { LabelWithIconAndTooltip } from "./components/LabelWithIconAndTooltip";
24
24
  import { PropertyFieldBinding } from "./PropertyFieldBinding";
25
25
  import { flattenKeys } from "@rebasepro/app";
26
26
  import { ErrorFocus } from "./components/ErrorFocus";
@@ -173,15 +173,30 @@ export function EntityForm<M extends Record<string, unknown>>({
173
173
  });
174
174
  };
175
175
 
176
- const [initialValues, initialDirty] = useMemo(() => {
177
- const initialValues = initialDirtyValues ? mergeDeep(baseInitialValues, initialDirtyValues) : baseInitialValues;
178
- const initialDirty = Boolean(initialDirtyValues) && initialDirtyValues && Object.keys(initialDirtyValues).length > 0;
179
- return [initialValues, initialDirty];
180
- }, [baseInitialValues, initialDirtyValues]);
176
+ /**
177
+ * What the form opens showing, when that is not what is stored — an edit
178
+ * carried over from the side panel's "open full screen", or a draft
179
+ * restored from the local-changes backup.
180
+ *
181
+ * The merge is deliberately *not* folded into the baseline. `mergeDeep`
182
+ * used to produce a single `initialValues` that served as both, with a
183
+ * `initialDirty: true` bolted on beside it to say what the baseline could
184
+ * no longer show — and that flag survived exactly one render, because the
185
+ * record's data arriving replaced the baseline and the form recomputed
186
+ * dirty as false against a baseline that already contained the edit.
187
+ *
188
+ * Read once, at mount: this is a handoff, and re-reading it every render
189
+ * tied the form to a cache that is cleared as soon as it has been consumed.
190
+ */
191
+ const initialModifiedValues = useRef(
192
+ initialDirtyValues && Object.keys(initialDirtyValues).length > 0
193
+ ? mergeDeep(baseInitialValues, initialDirtyValues)
194
+ : undefined
195
+ ).current;
181
196
 
182
197
  const internalFormex = useCreateFormex<M>({
183
- initialValues: initialValues as M,
184
- initialDirty,
198
+ initialValues: baseInitialValues as M,
199
+ initialModifiedValues: initialModifiedValues as M | undefined,
185
200
  debugId: `EntityForm:${path}/${entityIdProp}`,
186
201
  initialTouched: initialDirtyValues ?
187
202
  flattenKeys(initialDirtyValues!)
@@ -193,7 +208,9 @@ export function EntityForm<M extends Record<string, unknown>>({
193
208
  onSubmit,
194
209
  onReset: () => {
195
210
  onResetProp?.();
196
- onValuesModified?.(false, initialValues as M);
211
+ // The stored values — a reset returns the form to them, and by now
212
+ // that is the baseline whether or not it was at mount.
213
+ onValuesModified?.(false, baseInitialValues as M);
197
214
  },
198
215
  onValuesChangeDeferred: onValuesChangeDeferredProp,
199
216
  validation: async (values): Promise<Record<string, string>> => {
@@ -420,7 +437,7 @@ export function EntityForm<M extends Record<string, unknown>>({
420
437
  disabledProp
421
438
  || (!autoSave && formex.isSubmitting)
422
439
  || isReadOnly(property)
423
- || Boolean(property.admin?.disabled)
440
+ || isDisabled(property)
424
441
  || (!isNew && "isId" in property && Boolean(property.isId))
425
442
  || (isNew && isIdAndAuto)
426
443
  );
@@ -440,25 +457,26 @@ export function EntityForm<M extends Record<string, unknown>>({
440
457
  const child = AdditionalFieldBuilder
441
458
  ? <AdditionalFieldBuilder entity={entity} context={additionalFieldContext}/>
442
459
  : <div className={"w-full"}>
443
- {additionalField.value?.({
444
- entity,
445
- context: additionalFieldContext
446
- })?.toString()}
460
+ <AdditionalFieldValue
461
+ field={additionalField}
462
+ entity={entity}
463
+ context={additionalFieldContext}/>
447
464
  </div>;
448
465
 
449
466
  return (
467
+ // Through FieldBlock like every other cell, so a computed
468
+ // column is labelled at the same size, with the same gap to its
469
+ // box, as the property next to it on the grid.
450
470
  <div key={`additional_${field.key}`}
451
471
  className={spanClass(field.span)}>
452
- <LabelWithIconAndTooltip
453
- propertyKey={field.key}
454
- icon={<AlignLeftIcon size={iconSize.small}/>}
455
- title={additionalField.name}
456
- className={"text-text-secondary dark:text-text-secondary-dark"}/>
457
- <div className={cls(paperMixin, "w-full min-h-14 p-4 md:p-6 overflow-x-auto no-scrollbar")}>
458
- <ErrorBoundary>
472
+ <FieldBlock propertyKey={field.key}
473
+ showLabel={true}
474
+ label={additionalField.name}
475
+ icon={<AlignLeftIcon size={LABEL_ICON_SIZE}/>}>
476
+ <div className={cls(paperMixin, "w-full min-h-12 p-4 md:p-6 overflow-x-auto no-scrollbar")}>
459
477
  {child}
460
- </ErrorBoundary>
461
- </div>
478
+ </div>
479
+ </FieldBlock>
462
480
  </div>
463
481
  );
464
482
  }
@@ -537,12 +555,25 @@ export function EntityForm<M extends Record<string, unknown>>({
537
555
  }
538
556
  ];
539
557
 
540
- return <FormSections
541
- sections={sections}
542
- collapsed={collapsedSections}
543
- onToggle={toggleSection}
544
- errorKeys={errorKeys}
545
- renderField={renderField}/>;
558
+ return <>
559
+ <FormSections
560
+ sections={sections}
561
+ collapsed={collapsedSections}
562
+ onToggle={toggleSection}
563
+ errorKeys={errorKeys}
564
+ renderField={renderField}/>
565
+
566
+ {/* Folded back with the rail's fields, and for the same reason. The
567
+ rail renders only where it has been measured to fit, so on the
568
+ split pane, the side panel and the dialog this block was dropped
569
+ outright and the record's id was nowhere on screen but the
570
+ truncated chip in the bar. */}
571
+ {!showRail && layout.showRecordMeta && status === "existing" && entity && (
572
+ <div className={cls("mt-8 pt-5 border-t max-w-sm", defaultBorderMixin)}>
573
+ <RecordMeta entity={entity as Entity<Record<string, unknown>>}/>
574
+ </div>
575
+ )}
576
+ </>;
546
577
  };
547
578
 
548
579
  const formRef = useRef<HTMLDivElement>(null);
@@ -706,25 +737,6 @@ export function EntityForm<M extends Record<string, unknown>>({
706
737
  );
707
738
  }
708
739
 
709
- /**
710
- * Grid placement for a span.
711
- *
712
- * Written out as literal class strings rather than composed, because Tailwind
713
- * scans source text: a template literal would produce classes that exist at
714
- * runtime and were never generated into the stylesheet.
715
- *
716
- * Below the column breakpoint every field takes the single available column, so
717
- * the side panel, the dialog and mobile all get a plain stack.
718
- */
719
- function spanClass(span: 1 | 2 | 3 | 4): string {
720
- switch (span) {
721
- case 1: return "@2xl/col:col-span-1";
722
- case 2: return "@2xl/col:col-span-2";
723
- case 3: return "@2xl/col:col-span-3";
724
- case 4: return "@2xl/col:col-span-4";
725
- }
726
- }
727
-
728
740
  function useOnAutoSave<M extends Record<string, unknown>>(autoSave: undefined | boolean, formex: FormexController<M>, lastSavedValues: React.MutableRefObject<EntityValues<M> | undefined>, save: (values: EntityValues<M>) => Promise<Entity<M> | void>) {
729
741
  useEffect(() => {
730
742
  if (!autoSave) return;