@rebasepro/admin 0.13.1-canary.gef9608c → 0.14.0

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 (148) hide show
  1. package/dist/{CollectionEditorDialog-7b-kSLno.js → CollectionEditorDialog-CSheud_E.js} +19 -9
  2. package/dist/CollectionEditorDialog-CSheud_E.js.map +1 -0
  3. package/dist/{PropertyEditView-C-31uvpG.js → PropertyEditView-CWZlC1gq.js} +5 -5
  4. package/dist/PropertyEditView-CWZlC1gq.js.map +1 -0
  5. package/dist/{RouterCollectionsStudioView-BJylABq3.js → RouterCollectionsStudioView-CwTpdzWF.js} +9 -13
  6. package/dist/RouterCollectionsStudioView-CwTpdzWF.js.map +1 -0
  7. package/dist/collection_editor/serializable_types.d.ts +1 -0
  8. package/dist/collection_editor_ui.js +4 -4
  9. package/dist/components/ClearFilterSortButton.d.ts +7 -1
  10. package/dist/components/CollectionViewBinding/EntityCardBinding.d.ts +3 -1
  11. package/dist/components/CollectionViewBinding/SearchExplanation.d.ts +48 -0
  12. package/dist/components/CollectionViewBinding/SearchHighlight.d.ts +101 -0
  13. package/dist/components/CollectionViewBinding/SlotValue.d.ts +26 -0
  14. package/dist/components/CollectionViewBinding/SortButton.d.ts +7 -1
  15. package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +17 -6
  16. package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +1 -1
  17. package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +26 -1
  18. package/dist/components/DetailViewBinding.d.ts +7 -0
  19. package/dist/components/DrawerNavigationGroup.d.ts +4 -2
  20. package/dist/components/DrawerNavigationItem.d.ts +13 -5
  21. package/dist/components/EditViewBinding.d.ts +22 -7
  22. package/dist/components/EntityIdentityBar.d.ts +24 -1
  23. package/dist/components/RelationSelector.d.ts +13 -0
  24. package/dist/data_export/export/export.d.ts +7 -0
  25. package/dist/data_export/export/fetch_export_data.d.ts +48 -0
  26. package/dist/data_export/export/index.d.ts +1 -0
  27. package/dist/data_import/utils/csv.d.ts +30 -0
  28. package/dist/data_import/utils/index.d.ts +2 -0
  29. package/dist/data_import/utils/save_entities.d.ts +38 -0
  30. package/dist/data_import/utils/transforms.d.ts +9 -0
  31. package/dist/editor.js +3 -0
  32. package/dist/editor.js.map +1 -1
  33. package/dist/{export-C-XG3aRh.js → export-L53WektO.js} +175 -39
  34. package/dist/export-L53WektO.js.map +1 -0
  35. package/dist/form/form_utils.d.ts +38 -1
  36. package/dist/{history-DfMuvPfr.js → history-D5SKygpJ.js} +2 -2
  37. package/dist/{history-DfMuvPfr.js.map → history-D5SKygpJ.js.map} +1 -1
  38. package/dist/{import-B0GyllB0.js → import-C_4edkoD.js} +5 -4
  39. package/dist/import-C_4edkoD.js.map +1 -0
  40. package/dist/index.d.ts +2 -2
  41. package/dist/index.js +96 -80
  42. package/dist/index.js.map +1 -1
  43. package/dist/preview/components/date_fns_locales.d.ts +9 -0
  44. package/dist/util/view_constants.d.ts +24 -0
  45. package/dist/util/view_mode.d.ts +18 -0
  46. package/dist/{util-DHNXodBo.js → util-C-D5yD2n.js} +2173 -673
  47. package/dist/util-C-D5yD2n.js.map +1 -0
  48. package/package.json +10 -9
  49. package/src/collection_editor/serializable_types.ts +1 -0
  50. package/src/collection_editor/ui/collection_editor/AICollectionGeneratorPopover.tsx +8 -3
  51. package/src/collection_editor/ui/collection_editor/CollectionDetailsForm.tsx +5 -2
  52. package/src/collection_editor/ui/collection_editor/CollectionJsonImportDialog.tsx +1 -0
  53. package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +9 -8
  54. package/src/collection_editor/ui/collection_editor/CollectionsStudioView.tsx +9 -11
  55. package/src/collection_editor/ui/collection_editor/GeneralSettingsForm.tsx +5 -2
  56. package/src/collection_editor/ui/collection_editor/properties/MapPropertyField.tsx +1 -1
  57. package/src/collection_editor/ui/collection_editor/properties/advanced/AdvancedPropertyValidation.tsx +5 -2
  58. package/src/collection_editor/ui/collection_editor/properties/conditions/ConditionsEditor.tsx +1 -0
  59. package/src/collection_editor/useJsonCollectionsConfigController.tsx +92 -84
  60. package/src/collection_editor/useLocalCollectionsConfigController.tsx +10 -3
  61. package/src/components/ClearFilterSortButton.tsx +16 -7
  62. package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +2 -2
  63. package/src/components/CollectionTableBinding/PropertyTableCell.tsx +2 -2
  64. package/src/components/CollectionTableBinding/column_utils.tsx +15 -6
  65. package/src/components/CollectionTableBinding/internal/popup_field/PopupFormField.tsx +2 -2
  66. package/src/components/CollectionTableBinding/table_bindings.tsx +7 -1
  67. package/src/components/CollectionViewBinding/BoardCardBinding.tsx +18 -1
  68. package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +13 -7
  69. package/src/components/CollectionViewBinding/CollectionCardViewBinding.tsx +2 -1
  70. package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +816 -335
  71. package/src/components/CollectionViewBinding/CollectionViewActions.tsx +1 -1
  72. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +26 -6
  73. package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +9 -0
  74. package/src/components/CollectionViewBinding/EntityCardBinding.tsx +45 -10
  75. package/src/components/CollectionViewBinding/SearchExplanation.tsx +106 -0
  76. package/src/components/CollectionViewBinding/SearchHighlight.tsx +311 -0
  77. package/src/components/CollectionViewBinding/SlotValue.tsx +96 -0
  78. package/src/components/CollectionViewBinding/SortButton.tsx +16 -4
  79. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +23 -9
  80. package/src/components/CollectionViewBinding/SplitListShowButton.tsx +3 -3
  81. package/src/components/CollectionViewBinding/SplitListView.tsx +36 -9
  82. package/src/components/CollectionViewBinding/ViewModeToggle.tsx +10 -5
  83. package/src/components/CollectionViewBinding/useBoardDataController.tsx +11 -3
  84. package/src/components/CollectionViewBinding/usePreviewSlots.ts +76 -24
  85. package/src/components/CollectionViewBinding/utils.ts +7 -4
  86. package/src/components/DefaultDrawer.tsx +7 -0
  87. package/src/components/DetailViewBinding.tsx +33 -9
  88. package/src/components/DrawerNavigationGroup.tsx +12 -32
  89. package/src/components/DrawerNavigationItem.tsx +33 -12
  90. package/src/components/EditViewBinding.tsx +175 -28
  91. package/src/components/EntityIdentityBar.tsx +109 -17
  92. package/src/components/EntityInspector.tsx +24 -6
  93. package/src/components/EntityViewBinding.tsx +4 -2
  94. package/src/components/ReferenceTable/SelectionTableBinding.tsx +4 -2
  95. package/src/components/RelationSelector.tsx +61 -11
  96. package/src/components/SearchIconsView.tsx +14 -33
  97. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +1 -0
  98. package/src/components/SidePanelBinding.tsx +36 -25
  99. package/src/components/UserSelector.tsx +52 -9
  100. package/src/components/history/LastEditedByIndicator.tsx +7 -12
  101. package/src/data_export/export/ExportCollectionAction.tsx +101 -48
  102. package/src/data_export/export/export.ts +63 -16
  103. package/src/data_export/export/fetch_export_data.ts +90 -0
  104. package/src/data_export/export/index.ts +1 -0
  105. package/src/data_import/components/DataNewPropertiesMapping.tsx +20 -3
  106. package/src/data_import/components/ImportNewPropertyFieldPreview.tsx +1 -0
  107. package/src/data_import/components/ImportSaveInProgress.tsx +35 -36
  108. package/src/data_import/import/ImportCollectionAction.tsx +13 -2
  109. package/src/data_import/utils/csv.ts +153 -0
  110. package/src/data_import/utils/data.ts +16 -2
  111. package/src/data_import/utils/file_to_json.ts +77 -6
  112. package/src/data_import/utils/index.ts +2 -0
  113. package/src/data_import/utils/save_entities.ts +138 -0
  114. package/src/data_import/utils/transforms.ts +23 -0
  115. package/src/editor/components/image-bubble.tsx +2 -0
  116. package/src/editor/selectors/link-selector.tsx +1 -0
  117. package/src/form/EntityForm.tsx +12 -3
  118. package/src/form/EntityFormBinding.tsx +8 -12
  119. package/src/form/PropertyFieldBinding.tsx +2 -2
  120. package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +2 -2
  121. package/src/form/field_bindings/BinaryFieldBinding.tsx +1 -0
  122. package/src/form/field_bindings/BlockFieldBinding.tsx +3 -3
  123. package/src/form/field_bindings/DateTimeFieldBinding.tsx +4 -0
  124. package/src/form/field_bindings/KeyValueFieldBinding.tsx +3 -0
  125. package/src/form/field_bindings/MapFieldBinding.tsx +2 -2
  126. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +15 -2
  127. package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -3
  128. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +2 -2
  129. package/src/form/field_bindings/TextFieldBinding.tsx +18 -0
  130. package/src/form/field_bindings/VectorFieldBinding.tsx +1 -0
  131. package/src/form/form_utils.ts +78 -0
  132. package/src/index.ts +9 -1
  133. package/src/preview/PropertyPreview.tsx +7 -3
  134. package/src/preview/components/DatePreview.tsx +15 -2
  135. package/src/preview/components/ReferencePreview.tsx +5 -8
  136. package/src/preview/components/RelationPreview.tsx +8 -7
  137. package/src/preview/components/date_fns_locales.ts +125 -0
  138. package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +6 -3
  139. package/src/util/navigation_utils.ts +4 -2
  140. package/src/util/property_utils.tsx +6 -1
  141. package/src/util/view_constants.ts +27 -0
  142. package/src/util/view_mode.ts +36 -0
  143. package/dist/CollectionEditorDialog-7b-kSLno.js.map +0 -1
  144. package/dist/PropertyEditView-C-31uvpG.js.map +0 -1
  145. package/dist/RouterCollectionsStudioView-BJylABq3.js.map +0 -1
  146. package/dist/export-C-XG3aRh.js.map +0 -1
  147. package/dist/import-B0GyllB0.js.map +0 -1
  148. package/dist/util-DHNXodBo.js.map +0 -1
@@ -49,7 +49,14 @@ import { useNavigate } from "react-router";
49
49
  import { useEntityDisplayTitle } from "../hooks/useEntityDisplayTitle";
50
50
 
51
51
 
52
- import { MAIN_TAB_VALUE, JSON_TAB_VALUE, HISTORY_TAB_VALUE } from "../util/view_constants";
52
+ import {
53
+ MAIN_TAB_VALUE,
54
+ JSON_TAB_VALUE,
55
+ HISTORY_TAB_VALUE,
56
+ ENTITY_VIEW_TAB_BAR_CLASS,
57
+ ENTITY_VIEW_TAB_CLASS,
58
+ ENTITY_VIEW_TAB_LABEL_CLASS
59
+ } from "../util/view_constants";
53
60
 
54
61
  export type BarActionsParams = {
55
62
  values: object,
@@ -76,6 +83,13 @@ export interface DetailViewBindingProps<M extends Record<string, unknown> = Reco
76
83
  parentEntityIds: string[];
77
84
  onTabChange?: (props: OnTabChangeParams<M>) => void;
78
85
  onEditClick?: () => void;
86
+ /**
87
+ * Dismiss this record — the ✕ at the identity bar's trailing edge.
88
+ *
89
+ * Set by the layouts that own the record's place on screen but are not side
90
+ * dialogs: the split, where dismissing is navigating back to the collection.
91
+ */
92
+ onCloseRequest?: () => void;
79
93
  layout?: "side_panel" | "full_screen" | "split" | "dialog";
80
94
  barActions?: (params: BarActionsParams) => React.ReactNode;
81
95
  /**
@@ -144,6 +158,7 @@ function DetailViewBindingInner<M extends Record<string, unknown>>({
144
158
  parentEntityIds,
145
159
  onTabChange,
146
160
  onEditClick,
161
+ onCloseRequest,
147
162
  entity,
148
163
  dataLoading,
149
164
  layout = "full_screen",
@@ -197,7 +212,12 @@ entityId }
197
212
  }
198
213
  }, [selectedTabProp, defaultSelectedView]);
199
214
 
200
- const childViews = getAdminEntityChildViews(collection).filter(v => !v.collection.hideFromNavigation);
215
+ // `hideFromEntityViews`, not `hideFromNavigation`: the latter governs the
216
+ // drawer. Filtering tabs on it defeated the introspection generator, which
217
+ // sets `hideFromNavigation` on owned-child tables precisely *because* "every
218
+ // inbound foreign key renders as a tab on the parent" — so the rows it
219
+ // deliberately kept out of the drawer became reachable from nowhere.
220
+ const childViews = getAdminEntityChildViews(collection).filter(v => !v.collection.hideFromEntityViews);
201
221
  const subcollections = childViews.map(v => v.collection);
202
222
  const subcollectionsCount = subcollections?.length ?? 0;
203
223
  const customViews = collection.entityViews ?? [];
@@ -387,9 +407,10 @@ entityId }
387
407
  // centred column the record used to be given here; the default display
388
408
  // now lays itself out, rail included.
389
409
  if (FormViewBuilder && usedEntity) {
390
- return <div className={"flex-1 min-w-0 overflow-y-auto flex justify-center items-start"}>
410
+ return <div className={"@container/form flex-1 min-w-0 overflow-y-auto flex justify-center items-start"}>
391
411
  <div className={cls(
392
- "w-full max-w-3xl 2xl:max-w-4xl flex flex-col",
412
+ // Same widths as the default form (see EntityForm).
413
+ "w-full max-w-3xl @7xl/form:max-w-4xl 2xl:max-w-4xl flex flex-col",
393
414
  layout === "dialog"
394
415
  ? "pt-5 pb-12 px-6 sm:px-8"
395
416
  : "pt-6 pb-16 px-5 sm:px-8"
@@ -429,7 +450,7 @@ entityId }
429
450
  const icon = getIcon(subcollection.icon, undefined, undefined, "smallest");
430
451
  return (
431
452
  <Tab
432
- className="text-sm min-w-[90px]"
453
+ className={cls(ENTITY_VIEW_TAB_CLASS, ENTITY_VIEW_TAB_LABEL_CLASS)}
433
454
  value={subcollection.slug}
434
455
  key={`entity_detail_collection_tab_${subcollection.name}`}>
435
456
  <span className="flex items-center gap-1.5">
@@ -445,7 +466,7 @@ entityId }
445
466
  const icon = getIcon(view.icon, undefined, undefined, "smallest");
446
467
  return (
447
468
  <Tab
448
- className={!view.tabComponent ? "text-sm min-w-[90px]" : undefined}
469
+ className={cls(ENTITY_VIEW_TAB_CLASS, !view.tabComponent && ENTITY_VIEW_TAB_LABEL_CLASS)}
449
470
  value={view.key}
450
471
  key={`entity_detail_collection_tab_${view.name}`}>
451
472
  {view.tabComponent ?? (
@@ -462,7 +483,7 @@ entityId }
462
483
  const icon = getIcon(view.icon, undefined, undefined, "smallest");
463
484
  return (
464
485
  <Tab
465
- className={!view.tabComponent ? "text-sm min-w-[90px]" : undefined}
486
+ className={cls(ENTITY_VIEW_TAB_CLASS, !view.tabComponent && ENTITY_VIEW_TAB_LABEL_CLASS)}
466
487
  value={view.key}
467
488
  key={`entity_detail_collection_tab_${view.name}`}>
468
489
  {view.tabComponent ?? (
@@ -551,6 +572,7 @@ entityId }
551
572
  dirty={false}
552
573
  saving={false}
553
574
  onBack={backToCollection}
575
+ onClose={onCloseRequest}
554
576
  onInspect={includeJsonView ? () => setInspectorTab("json") : undefined}
555
577
  onViewHistory={includeHistoryView ? () => setInspectorTab("history") : undefined}
556
578
  externalLink={usedEntity
@@ -580,10 +602,11 @@ entityId }
580
602
 
581
603
  {hasAdditionalViews && <div className={cls(
582
604
  "h-10 shrink-0 flex items-stretch border-b px-2 min-w-0",
605
+ ENTITY_VIEW_TAB_BAR_CLASS,
583
606
  defaultBorderMixin
584
607
  )}>
585
608
  <Tabs
586
- className={"!w-full"}
609
+ className={"!w-full items-stretch"}
587
610
  innerClassName={"h-full"}
588
611
  variant={"boxy"}
589
612
  value={activeTab}
@@ -591,7 +614,8 @@ entityId }
591
614
  onSideTabClick(value);
592
615
  }}>
593
616
 
594
- <Tab value={MAIN_TAB_VALUE}>
617
+ <Tab value={MAIN_TAB_VALUE}
618
+ className={cls(ENTITY_VIEW_TAB_CLASS, ENTITY_VIEW_TAB_LABEL_CLASS)}>
595
619
  <span className="flex items-center gap-1.5">
596
620
  {getIcon(collection.icon, undefined, undefined, "smallest")}
597
621
  {collection.singularName ?? collection.name}
@@ -51,8 +51,10 @@ export interface DrawerNavigationGroupProps {
51
51
  /**
52
52
  * Lucide icon name for the group header, from `NavigationGroupMapping.icon`.
53
53
  *
54
- * Supplying one moves the visual anchor from the rows to the group: the header
55
- * gets the icon, and the entries below trade theirs for an indent.
54
+ * It decorates the header and nothing else: the entries below are untouched and
55
+ * keep their own icons. An app that wants the categorised look — rows giving up
56
+ * their icons to indent under the group — builds it in its own drawer, by
57
+ * overriding `Shell.DrawerNavigationItem` and passing `indented`.
56
58
  */
57
59
  icon?: string;
58
60
  }
@@ -75,17 +77,13 @@ export function DrawerNavigationGroup({
75
77
  const { t } = useTranslation();
76
78
  const ResolvedDrawerNavigationItem = useComponentOverride("Shell.DrawerNavigationItem", DrawerNavigationItem);
77
79
 
78
- // Opt-in, per group: declaring `icon` on a `NavigationGroupMapping` switches
79
- // this group to the categorised treatment — icon on the header, entries
80
- // indented beneath it without their own. A group that declares no icon renders
81
- // exactly as it did before, so an existing project sees no change at all.
82
- //
83
- // Two cases stay on the flat rendering regardless: with no header there is
84
- // nothing to indent under, and collapsed to a rail the entry icons are the only
85
- // affordance left to click.
80
+ // The icon decorates the header, and stops there. It used to also strip the
81
+ // entries of theirs and indent them, which made a per-app styling choice into
82
+ // framework behaviour: every project that labelled a group with an icon lost the
83
+ // icons on its rows, with nothing to turn it off. An app that wants that look
84
+ // now opts into it in its own drawer — see `indented` on
85
+ // {@link DrawerNavigationItem}.
86
86
  const groupIcon = !hideHeader && drawerOpen ? getIcon(icon, undefined, undefined, "small") : undefined;
87
- const categorised = Boolean(groupIcon);
88
- const indentEntries = categorised;
89
87
  return (
90
88
  <div
91
89
  className={"my-2 mx-2 flex flex-col"}
@@ -102,10 +100,7 @@ export function DrawerNavigationGroup({
102
100
  <ChevronDownIcon
103
101
  size={iconSize.small}
104
102
  className={cls(
105
- categorised
106
- ? "text-surface-500 dark:text-surface-400"
107
- : "text-surface-400 dark:text-surface-400",
108
- "transition-transform duration-200 mr-1",
103
+ "text-surface-400 dark:text-surface-400 transition-transform duration-200 mr-1",
109
104
  collapsed ? "-rotate-90" : "rotate-0"
110
105
  )}
111
106
  />
@@ -117,21 +112,7 @@ export function DrawerNavigationGroup({
117
112
  <Typography
118
113
  variant={"caption"}
119
114
  color={"secondary"}
120
- // Two treatments, and which one applies is decided by the group
121
- // itself. Without an icon this is byte-for-byte the original: the
122
- // drawer is a flat list of same-weight rows, and a louder header
123
- // would just compete with them.
124
- //
125
- // With one, the group becomes the thing you scan — its rows have
126
- // given up their icons to indent beneath it — so the label steps up
127
- // to match. At 11px in surface-400 it sat *below* the contrast of
128
- // the rows it labels, which is backwards once it is the anchor.
129
- className={cls(
130
- "font-semibold uppercase flex-grow line-clamp-1",
131
- categorised
132
- ? "text-[12px] tracking-wide text-surface-600 dark:text-surface-300"
133
- : "text-[11px] tracking-wider text-surface-400 dark:text-surface-400"
134
- )}
115
+ className="font-semibold text-[11px] uppercase tracking-wider flex-grow line-clamp-1 text-surface-400 dark:text-surface-400"
135
116
  >
136
117
  {(group || t("views_group"))}
137
118
  </Typography>
@@ -161,7 +142,6 @@ export function DrawerNavigationGroup({
161
142
  onClick={() => onItemClick?.(entry)}
162
143
  url={entry.url}
163
144
  name={entry.name}
164
- indented={indentEntries}
165
145
  />
166
146
  ))}
167
147
  </div>
@@ -3,14 +3,11 @@ import React from "react";
3
3
  import { NavLink } from "react-router";
4
4
  import { cls, Tooltip } from "@rebasepro/ui";
5
5
 
6
- export function DrawerNavigationItem({
7
- name,
8
- icon,
9
- drawerOpen,
10
- url,
11
- onClick,
12
- indented = false
13
- }: {
6
+ /**
7
+ * Props of a drawer navigation row, named so an app overriding
8
+ * `Shell.DrawerNavigationItem` can type its wrapper against them.
9
+ */
10
+ export type DrawerNavigationItemProps = {
14
11
  icon: React.ReactElement,
15
12
  name: string,
16
13
  /** @deprecated No longer read: the tooltip is uncontrolled. */
@@ -21,12 +18,24 @@ export function DrawerNavigationItem({
21
18
  url: string,
22
19
  onClick?: () => void,
23
20
  /**
24
- * Render as a child of an icon-bearing group: no icon of its own, indented to
25
- * sit under the group's label. Set by {@link DrawerNavigationGroup} only when
26
- * the group actually has an icon and the drawer is expanded.
21
+ * Drop the row's icon and indent the label into the space it occupied, so the
22
+ * row reads as a child of the group header above it.
23
+ *
24
+ * Nothing in the framework sets this: the stock drawer always passes `false`,
25
+ * and an app that wants the categorised look opts into it by overriding
26
+ * `Shell.DrawerNavigationItem` and deciding for itself which rows get it.
27
27
  */
28
28
  indented?: boolean,
29
- }) {
29
+ };
30
+
31
+ export function DrawerNavigationItem({
32
+ name,
33
+ icon,
34
+ drawerOpen,
35
+ url,
36
+ onClick,
37
+ indented = false
38
+ }: DrawerNavigationItemProps) {
30
39
 
31
40
  // Indented rows give up the icon rather than keeping it *and* indenting: a
32
41
  // per-row icon repeated down a group is what flattens the hierarchy, since
@@ -52,6 +61,18 @@ export function DrawerNavigationItem({
52
61
  const listItem = <div>
53
62
  <NavLink
54
63
  onClick={onClick}
64
+ // The label below is `hidden` (display:none) while the rail is
65
+ // collapsed, which takes it out of the accessibility tree along with
66
+ // the row's only text — a collapsed rail was a column of links with no
67
+ // accessible name at all. The tooltip does not cover this: Radix wires
68
+ // a trigger to its content with `aria-describedby`, which is a
69
+ // description rather than a name, and only while the tooltip is open —
70
+ // and this one is masked shut whenever the drawer is open.
71
+ //
72
+ // Named unconditionally rather than only when collapsed: expanded, this
73
+ // is the same string the row already displays, so the accessible name
74
+ // stays stable across the transition instead of appearing with it.
75
+ aria-label={name}
55
76
  style={{
56
77
  width: "100%",
57
78
  transition: drawerOpen ? "width 150ms ease-in" : undefined
@@ -38,8 +38,9 @@ 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";
@@ -54,10 +55,17 @@ import { useEntityDisplayTitle } from "../hooks/useEntityDisplayTitle";
54
55
  import { createFormexStub, getEntityFromCache } from "@rebasepro/app";
55
56
  import { usePermissions } from "@rebasepro/app";
56
57
  import { useUrlController } from "../hooks/navigation/contexts/UrlContext";
57
- import { withViewMode } from "../util/view_mode";
58
+ import { withListState } from "../util/view_mode";
58
59
  import { useNavigate } from "react-router";
59
60
 
60
- import { MAIN_TAB_VALUE, JSON_TAB_VALUE, HISTORY_TAB_VALUE } from "../util/view_constants";
61
+ import {
62
+ MAIN_TAB_VALUE,
63
+ JSON_TAB_VALUE,
64
+ HISTORY_TAB_VALUE,
65
+ ENTITY_VIEW_TAB_BAR_CLASS,
66
+ ENTITY_VIEW_TAB_CLASS,
67
+ ENTITY_VIEW_TAB_LABEL_CLASS
68
+ } from "../util/view_constants";
61
69
  export { MAIN_TAB_VALUE, JSON_TAB_VALUE, HISTORY_TAB_VALUE };
62
70
 
63
71
  export type BarActionsParams = {
@@ -65,14 +73,19 @@ export type BarActionsParams = {
65
73
  status: EntityStatus,
66
74
  path: string,
67
75
  entityId?: string | number;
76
+ /** Whether `values` differ from what is stored. */
77
+ dirty: boolean;
68
78
  /**
69
- * Whether `values` differ from what is stored. The side panel's "open full
70
- * screen" hands the in-flight edit to the full-screen route through the
71
- * memory cache, and a record loaded from that cache opens *dirty*: without
72
- * this it stashed the values unconditionally, so expanding a record you had
73
- * only looked at arrived saying "Unsaved changes" with Save enabled.
79
+ * Hand the edit in progress to the layout this action is about to open, so
80
+ * the record arrives showing — and still holding — what the user typed here.
81
+ * Call it from any action that changes layout without leaving the record;
82
+ * it decides on its own whether there is anything worth carrying, so a
83
+ * record nobody edited still arrives clean.
84
+ *
85
+ * Only supplied where there is a form to carry: the detail view passes no
86
+ * such thing.
74
87
  */
75
- dirty: boolean;
88
+ carryEdit?: () => void;
76
89
  };
77
90
 
78
91
  export type OnTabChangeParams<M extends Record<string, unknown>> = {
@@ -98,6 +111,16 @@ export interface EditViewBindingProps<M extends Record<string, unknown> = Record
98
111
  onSaved?: (params: OnUpdateParams) => void;
99
112
  onTabChange?: (props: OnTabChangeParams<M>) => void;
100
113
  navigateBack?: () => void;
114
+ /**
115
+ * Dismiss this record — the ✕ at the identity bar's trailing edge, and what
116
+ * "save and close" reaches once the save lands.
117
+ *
118
+ * Set by the layouts that own the record's place on screen but are not side
119
+ * dialogs: the split, where dismissing is navigating back to the collection.
120
+ * The side panel and the dialog close through {@link useSideDialogContext}
121
+ * instead, which is what runs their unsaved-changes prompt.
122
+ */
123
+ onCloseRequest?: () => void;
101
124
  layout?: "side_panel" | "full_screen" | "split" | "dialog";
102
125
  barActions?: (params: BarActionsParams) => React.ReactNode;
103
126
  /**
@@ -142,9 +165,39 @@ export function EditViewBinding<M extends Record<string, unknown>>({
142
165
  useCache: false
143
166
  });
144
167
 
145
- const initialDirtyValues = entityId
146
- ? getEntityFromMemoryCache(props.path + "/" + entityId)
147
- : (props.defaultValues ?? getEntityFromMemoryCache(props.path + "#new"));
168
+ // The edit handed over by another layout of this same record — see
169
+ // {@link carryEdit} below for the sending half.
170
+ //
171
+ // Read once per record and consumed: this is a handoff, and an edit left in
172
+ // the channel after it has been picked up is one that reopens the record
173
+ // dirty on a later, unrelated visit, carrying values the user walked away
174
+ // from. Held in a ref rather than re-read each render, because the read has
175
+ // to survive the clear — {@link EntityFormBinding} measures the
176
+ // local-changes banner against what the form opens showing, and a prop that
177
+ // reverted to `undefined` once the cache was cleared would raise the banner
178
+ // over the very edit that was just carried in.
179
+ //
180
+ // A copy is keyed with the new records, not under the record it was copied
181
+ // from: it is going to be stored as its own row, and both the handoff and
182
+ // the local-changes backup have always keyed it that way from the writing
183
+ // side. Reading it under `path/id` meant a copy in progress was written to
184
+ // one key and looked for under another.
185
+ const carriesAsNew = props.copy || entityId === undefined;
186
+ const handoffKey = carriesAsNew ? props.path + "#new" : props.path + "/" + entityId;
187
+ const handoff = useRef<{ key: string, values: object | undefined } | null>(null);
188
+ if (handoff.current?.key !== handoffKey) {
189
+ handoff.current = {
190
+ key: handoffKey,
191
+ values: getEntityFromMemoryCache(handoffKey)
192
+ };
193
+ }
194
+ useEffect(() => {
195
+ removeEntityFromMemoryCache(handoffKey);
196
+ }, [handoffKey]);
197
+
198
+ const initialDirtyValues = carriesAsNew
199
+ ? (props.defaultValues ?? handoff.current.values)
200
+ : handoff.current.values;
148
201
 
149
202
  const { canEdit: canEditHook } = usePermissions();
150
203
 
@@ -201,6 +254,7 @@ export function EditViewBindingInner<M extends Record<string, unknown>>({
201
254
  onSaved,
202
255
  onTabChange,
203
256
  navigateBack,
257
+ onCloseRequest,
204
258
  entity,
205
259
  initialDirtyValues,
206
260
  dataLoading,
@@ -287,7 +341,9 @@ parentEntityIds,
287
341
  }
288
342
  }, [selectedTabProp, defaultSelectedView]);
289
343
 
290
- const childViews = getAdminEntityChildViews(collection).filter(v => !v.collection.hideFromNavigation);
344
+ // See DetailViewBinding: tabs are governed by `hideFromEntityViews`, the
345
+ // drawer by `hideFromNavigation`. They are separate roles.
346
+ const childViews = getAdminEntityChildViews(collection).filter(v => !v.collection.hideFromEntityViews);
291
347
  const subcollections = childViews.map(v => v.collection);
292
348
  const subcollectionsCount = subcollections?.length ?? 0;
293
349
  const customViews = collection.entityViews ?? [];
@@ -436,6 +492,35 @@ parentEntityIds,
436
492
  || (status === "existing" && !formex?.dirty)
437
493
  );
438
494
 
495
+ /**
496
+ * Hand this record's edit in progress to the layout about to replace this
497
+ * one — the split's "hide list", full screen's "show list", the side panel's
498
+ * "open full screen". None of them is a navigation *away* from the record:
499
+ * the same record is about to be shown by a different form, and what the
500
+ * user has typed belongs to the record, not to the pane it was typed in.
501
+ *
502
+ * Every control that changes layout has to call this, and none of them is
503
+ * blocked by the unsaved-changes prompt — the prompt asks about leaving, and
504
+ * these do not leave. What used to happen instead is that the edit reached
505
+ * the next layout only as its local-changes backup, which is the channel for
506
+ * a draft left behind by a closed tab: the record reopened *clean*, with a
507
+ * banner offering to apply changes the user had never walked away from.
508
+ */
509
+ const carryEdit = useCallback(() => {
510
+ const carried = formex
511
+ ? getEditHandoffValues<M>({
512
+ status,
513
+ dirty: formex.dirty,
514
+ values: formex.values,
515
+ touched: formex.touched,
516
+ storedValues: usedEntity?.values
517
+ })
518
+ : undefined;
519
+ if (!carried) return;
520
+ const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
521
+ saveEntityToMemoryCache(key, carried);
522
+ }, [formex, status, path, entityId, usedEntity]);
523
+
439
524
  const [inspectorTab, setInspectorTab] = useState<InspectorTab | null>(null);
440
525
  // A save adds a revision, and the inspector can be open while it happens:
441
526
  // Save lives in the identity bar, above the panel. Counting saves gives the
@@ -445,7 +530,26 @@ parentEntityIds,
445
530
  /* ---- record actions, in the bar's overflow menu ---------------------- */
446
531
 
447
532
  const sideDialogContext = useSideDialogContext();
448
- const canCloseAfterSave = layout === "side_panel" || layout === "dialog";
533
+
534
+ /**
535
+ * Whether the save in flight was asked to dismiss the record afterwards.
536
+ *
537
+ * A ref, and for the reason {@link SideDialogs} keeps its own as one: the
538
+ * flag is raised and the form submitted in a single click handler, and the
539
+ * `onSaved` that has to act on it was captured before React could re-render.
540
+ * As state it read one save late — the first "save and close" saved and
541
+ * stayed open, and the *next* save closed the record.
542
+ *
543
+ * The side panel and the dialog use the side-dialog controller's flag,
544
+ * which their own close path consumes; this one covers the layouts that
545
+ * close by calling {@link onCloseRequest} instead.
546
+ */
547
+ const pendingCloseRef = useRef(false);
548
+
549
+ // The split can close after a save the same way the overlays do — through
550
+ // the callback its layout supplies. Without one there is nothing to close.
551
+ const canCloseAfterSave = layout === "side_panel" || layout === "dialog"
552
+ || (layout === "split" && Boolean(onCloseRequest));
449
553
 
450
554
  const recordActions = useRecordActions({
451
555
  collection,
@@ -547,10 +651,11 @@ parentEntityIds,
547
651
  const entityReadOnlyView = !canEdit && entity ? (
548
652
  FormViewBuilder && readOnlyFormContext
549
653
  ? <div className={cls(
550
- "flex-1 flex flex-row w-full overflow-y-auto justify-center",
654
+ "@container/form flex-1 flex flex-row w-full overflow-y-auto justify-center",
551
655
  (canEdit || !mainViewVisible || selectedSecondaryForm) ? "hidden" : ""
552
656
  )}>
553
- <div className={"w-full max-w-3xl 2xl:max-w-4xl flex flex-col pt-6 pb-16 px-5 sm:px-8"}>
657
+ {/* Same widths as the default form (see EntityForm). */}
658
+ <div className={"w-full max-w-3xl @7xl/form:max-w-4xl 2xl:max-w-4xl flex flex-col pt-6 pb-16 px-5 sm:px-8"}>
554
659
  <ErrorBoundary>
555
660
  <Suspense fallback={<CircularProgressCenter />}>
556
661
  <FormViewBuilder
@@ -629,8 +734,32 @@ parentEntityIds,
629
734
  ...params,
630
735
  selectedTab: MAIN_TAB_VALUE === activeTab ? undefined : activeTab
631
736
  };
737
+ // Two caller-supplied callbacks for one save, and either may
738
+ // navigate — the layouts' `onSaved` all do. Where both do, the
739
+ // second one's destination is what survives (docs/bug-classes.md
740
+ // #28), so `formProps.onSaved` is deliberately last: it belongs
741
+ // to whoever embedded this view, which outranks the layout.
632
742
  onSaved?.(res);
633
743
  formProps?.onSaved?.(res);
744
+
745
+ // After `onSaved`, never instead of it: the layout's own handler
746
+ // is what lowers its dirty flag, and closing is a navigation the
747
+ // unsaved-changes blocker would otherwise stop on values that
748
+ // are already stored.
749
+ //
750
+ // And after this handler returns, not inside it. The layouts
751
+ // that close this way close by navigating, and they have just
752
+ // navigated themselves — the split leaves `…/edit` for the
753
+ // record's own URL. Deferring keeps the close unambiguously
754
+ // last, which is what decides it: of two navigations raised for
755
+ // one action the final one survives, measured in
756
+ // `router_two_navigations_one_handler.test.tsx`. It also lets
757
+ // the state those handlers just set settle before the blocker
758
+ // reads it. See docs/bug-classes.md #28.
759
+ if (pendingCloseRef.current) {
760
+ pendingCloseRef.current = false;
761
+ setTimeout(() => onCloseRequest?.(), 0);
762
+ }
634
763
  }}
635
764
  Builder={resolveComponentRef(selectedSecondaryForm?.Builder as ComponentRef<EntityCustomViewParams<M>> | undefined) as React.ComponentType<EntityCustomViewParams<M>> | undefined}
636
765
  />
@@ -640,7 +769,7 @@ parentEntityIds,
640
769
  const icon = getIcon(subcollection.icon, undefined, undefined, "smallest");
641
770
  return (
642
771
  <Tab
643
- className="text-sm min-w-[90px]"
772
+ className={cls(ENTITY_VIEW_TAB_CLASS, ENTITY_VIEW_TAB_LABEL_CLASS)}
644
773
  value={subcollection.slug}
645
774
  key={`entity_detail_collection_tab_${subcollection.name}`}>
646
775
  <span className="flex items-center gap-1.5">
@@ -656,7 +785,7 @@ parentEntityIds,
656
785
  const icon = getIcon(view.icon, undefined, undefined, "smallest");
657
786
  return (
658
787
  <Tab
659
- className={!view.tabComponent ? "text-sm min-w-[90px]" : undefined}
788
+ className={cls(ENTITY_VIEW_TAB_CLASS, !view.tabComponent && ENTITY_VIEW_TAB_LABEL_CLASS)}
660
789
  value={view.key}
661
790
  key={`entity_detail_collection_tab_${view.name}`}>
662
791
  {view.tabComponent ?? (
@@ -673,7 +802,7 @@ parentEntityIds,
673
802
  const icon = getIcon(view.icon, undefined, undefined, "smallest");
674
803
  return (
675
804
  <Tab
676
- className={!view.tabComponent ? "text-sm min-w-[90px]" : undefined}
805
+ className={cls(ENTITY_VIEW_TAB_CLASS, !view.tabComponent && ENTITY_VIEW_TAB_LABEL_CLASS)}
677
806
  value={view.key}
678
807
  key={`entity_detail_collection_tab_${view.name}`}>
679
808
  {view.tabComponent ?? (
@@ -693,6 +822,7 @@ parentEntityIds,
693
822
  <IconButton
694
823
  size="small"
695
824
  onClick={() => {
825
+ carryEdit();
696
826
  const editSuffix = collection.defaultEntityAction === "view" ? "/edit" : "";
697
827
  const entityUrl = urlController.buildUrlCollectionPath(`${path}/${entityId}${editSuffix}`);
698
828
  navigate(`${entityUrl}#full`);
@@ -708,7 +838,7 @@ parentEntityIds,
708
838
  <EntityIdentityBar
709
839
  collection={collection as AdminCollection}
710
840
  collectionUrl={layout === "full_screen" || layout === "split"
711
- ? withViewMode(urlController.buildUrlCollectionPath(path))
841
+ ? withListState(urlController.buildUrlCollectionPath(path))
712
842
  : undefined}
713
843
  title={displayTitle}
714
844
  entityId={status === "existing" ? entityId : undefined}
@@ -723,22 +853,32 @@ parentEntityIds,
723
853
  // list can be unfolded back beside the record, that chevron reaches
724
854
  // the same collection and the arrow beside it is noise.
725
855
  onBack={layout === "full_screen" && !onShowList
726
- ? () => navigate(withViewMode(urlController.buildUrlCollectionPath(path)))
856
+ ? () => navigate(withListState(urlController.buildUrlCollectionPath(path)))
727
857
  : undefined}
728
858
  onSave={canEdit && formContext ? () => {
729
859
  sideDialogContext.setPendingClose?.(false);
860
+ pendingCloseRef.current = false;
730
861
  formContext.submit();
731
862
  } : undefined}
732
863
  onSaveAndClose={canEdit && formContext && canCloseAfterSave ? () => {
733
- sideDialogContext.setPendingClose?.(true);
734
864
  // Lowered again once the submit settles. A submit the form
735
865
  // rejects never reaches `onSaved`, so nothing would consume the
736
866
  // flag and the *next* save — a keyboard ⌘S, say — would close
737
867
  // the panel out from under an edit nobody asked to finish. A
738
868
  // save that succeeds has already closed by the time this runs.
869
+ if (layout === "split") {
870
+ pendingCloseRef.current = true;
871
+ Promise.resolve(formContext.submit())
872
+ .finally(() => { pendingCloseRef.current = false; });
873
+ return;
874
+ }
875
+ sideDialogContext.setPendingClose?.(true);
739
876
  Promise.resolve(formContext.submit())
740
877
  .finally(() => sideDialogContext.setPendingClose?.(false));
741
878
  } : undefined}
879
+ // Welded to Save in the split, its own button in the overlays.
880
+ saveAndClosePlacement={layout === "split" ? "menu" : "button"}
881
+ onClose={onCloseRequest}
742
882
  onDiscard={canEdit && formContext ? () => formContext.formex.resetForm() : undefined}
743
883
  onInspect={includeJsonView ? () => setInspectorTab("json") : undefined}
744
884
  onViewHistory={includeHistoryView ? () => setInspectorTab("history") : undefined}
@@ -751,9 +891,13 @@ parentEntityIds,
751
891
  {barActionsStart}
752
892
  {/* Split view: closing the list is opening this record
753
893
  full screen, and the control for it belongs at this
754
- bar's leading edge. Full screen carries its mirror. */}
755
- {layout === "split" && <SplitListCloseButton/>}
756
- {layout === "full_screen" && onShowList && <SplitListShowButton onClick={onShowList}/>}
894
+ bar's leading edge. Full screen carries its mirror.
895
+ Both change layout, so both hand the edit over. */}
896
+ {layout === "split" && <SplitListCloseButton onBeforeHide={carryEdit}/>}
897
+ {layout === "full_screen" && onShowList && <SplitListShowButton onClick={() => {
898
+ carryEdit();
899
+ onShowList();
900
+ }}/>}
757
901
  </>}
758
902
  trailing={<>
759
903
  {pluginActionsTop}
@@ -763,7 +907,8 @@ parentEntityIds,
763
907
  entityId,
764
908
  values: formContext?.values ?? usedEntity?.values ?? {},
765
909
  status,
766
- dirty: Boolean(formContext?.formex?.dirty)
910
+ dirty: Boolean(formContext?.formex?.dirty),
911
+ carryEdit
767
912
  })}
768
913
  </>}
769
914
  />
@@ -773,10 +918,11 @@ parentEntityIds,
773
918
  disappears entirely when there is nowhere else to go. */}
774
919
  {hasAdditionalViews && <div className={cls(
775
920
  "h-10 shrink-0 flex items-stretch border-b px-2 min-w-0",
921
+ ENTITY_VIEW_TAB_BAR_CLASS,
776
922
  defaultBorderMixin
777
923
  )}>
778
924
  <Tabs
779
- className={"!w-full"}
925
+ className={"!w-full items-stretch"}
780
926
  innerClassName={"h-full"}
781
927
  variant={"boxy"}
782
928
  value={activeTab}
@@ -784,7 +930,8 @@ parentEntityIds,
784
930
  onSideTabClick(value);
785
931
  }}>
786
932
 
787
- <Tab value={MAIN_TAB_VALUE}>
933
+ <Tab value={MAIN_TAB_VALUE}
934
+ className={cls(ENTITY_VIEW_TAB_CLASS, ENTITY_VIEW_TAB_LABEL_CLASS)}>
788
935
  <span className="flex items-center gap-1.5">
789
936
  {getIcon(collection.icon, undefined, undefined, "smallest")}
790
937
  {collection.singularName ?? collection.name}