@rebasepro/admin 0.13.0 → 0.13.1-canary.g1822133

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 (210) hide show
  1. package/dist/{CollectionEditorDialog-CjXkWZ64.js → CollectionEditorDialog-CSheud_E.js} +19 -9
  2. package/dist/CollectionEditorDialog-CSheud_E.js.map +1 -0
  3. package/dist/{PropertyEditView-W8I3YV89.js → PropertyEditView-CWZlC1gq.js} +5 -5
  4. package/dist/PropertyEditView-CWZlC1gq.js.map +1 -0
  5. package/dist/{RouterCollectionsStudioView-BsLTNoqX.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/AdditionalFieldValue.d.ts +25 -0
  10. package/dist/components/ClearFilterSortButton.d.ts +7 -1
  11. package/dist/components/CollectionViewBinding/CollectionViewBinding.d.ts +16 -0
  12. package/dist/components/CollectionViewBinding/EntityCardBinding.d.ts +3 -1
  13. package/dist/components/CollectionViewBinding/SearchExplanation.d.ts +48 -0
  14. package/dist/components/CollectionViewBinding/SearchHighlight.d.ts +101 -0
  15. package/dist/components/CollectionViewBinding/SlotValue.d.ts +63 -0
  16. package/dist/components/CollectionViewBinding/SortButton.d.ts +7 -1
  17. package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +17 -4
  18. package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +12 -0
  19. package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +55 -5
  20. package/dist/components/DetailViewBinding.d.ts +15 -0
  21. package/dist/components/DrawerNavigationGroup.d.ts +9 -6
  22. package/dist/components/DrawerNavigationItem.d.ts +16 -6
  23. package/dist/components/EditViewBinding.d.ts +30 -1
  24. package/dist/components/EntityDisplayHeader.d.ts +53 -0
  25. package/dist/components/EntityIdentityBar.d.ts +43 -1
  26. package/dist/components/EntityViewBinding.d.ts +60 -6
  27. package/dist/components/RebaseLayout.d.ts +1 -1
  28. package/dist/components/RebaseShell.d.ts +1 -1
  29. package/dist/components/RelationSelector.d.ts +21 -1
  30. package/dist/components/UserSelector.d.ts +8 -1
  31. package/dist/components/app/Scaffold.d.ts +9 -3
  32. package/dist/components/index.d.ts +2 -0
  33. package/dist/data_export/export/export.d.ts +7 -0
  34. package/dist/data_export/export/fetch_export_data.d.ts +48 -0
  35. package/dist/data_export/export/index.d.ts +1 -0
  36. package/dist/data_import/utils/csv.d.ts +30 -0
  37. package/dist/data_import/utils/index.d.ts +2 -0
  38. package/dist/data_import/utils/save_entities.d.ts +38 -0
  39. package/dist/data_import/utils/transforms.d.ts +9 -0
  40. package/dist/editor.js +3 -0
  41. package/dist/editor.js.map +1 -1
  42. package/dist/{export-J79rMoNj.js → export-L53WektO.js} +175 -39
  43. package/dist/export-L53WektO.js.map +1 -0
  44. package/dist/form/components/FieldBlock.d.ts +58 -4
  45. package/dist/form/components/FormRail.d.ts +13 -0
  46. package/dist/form/components/FormSections.d.ts +15 -1
  47. package/dist/form/components/useRailVisible.d.ts +5 -4
  48. package/dist/form/form_utils.d.ts +38 -1
  49. package/dist/{history-D7L_JlLO.js → history-D5SKygpJ.js} +4 -4
  50. package/dist/{history-D7L_JlLO.js.map → history-D5SKygpJ.js.map} +1 -1
  51. package/dist/hooks/useEntityDisplay.d.ts +102 -0
  52. package/dist/hooks/useEntityDisplayTitle.d.ts +18 -9
  53. package/dist/{import-BvYl--U4.js → import-C_4edkoD.js} +6 -5
  54. package/dist/import-C_4edkoD.js.map +1 -0
  55. package/dist/index.d.ts +2 -2
  56. package/dist/index.js +237 -123
  57. package/dist/index.js.map +1 -1
  58. package/dist/preview/compact_text.d.ts +23 -0
  59. package/dist/preview/components/BooleanPreview.d.ts +3 -1
  60. package/dist/preview/components/date_fns_locales.d.ts +9 -0
  61. package/dist/preview/property_previews/ArrayOfStringsPreview.d.ts +1 -1
  62. package/dist/preview/property_previews/ArrayOneOfPreview.d.ts +1 -1
  63. package/dist/preview/property_previews/ArrayPropertyPreview.d.ts +1 -1
  64. package/dist/preview/property_previews/MapPropertyPreview.d.ts +1 -1
  65. package/dist/preview/property_previews/StringPropertyPreview.d.ts +1 -1
  66. package/dist/routes/RebaseRoute.d.ts +1 -1
  67. package/dist/types/components/PropertyPreviewProps.d.ts +19 -0
  68. package/dist/util/number_format.d.ts +14 -0
  69. package/dist/util/previews.d.ts +9 -2
  70. package/dist/util/view_constants.d.ts +24 -0
  71. package/dist/util/view_mode.d.ts +18 -0
  72. package/dist/{util-GiwPgxnL.js → util-C-D5yD2n.js} +4161 -1809
  73. package/dist/util-C-D5yD2n.js.map +1 -0
  74. package/package.json +10 -9
  75. package/src/collection_editor/serializable_types.ts +1 -0
  76. package/src/collection_editor/ui/collection_editor/AICollectionGeneratorPopover.tsx +8 -3
  77. package/src/collection_editor/ui/collection_editor/CollectionDetailsForm.tsx +5 -2
  78. package/src/collection_editor/ui/collection_editor/CollectionJsonImportDialog.tsx +1 -0
  79. package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +9 -8
  80. package/src/collection_editor/ui/collection_editor/CollectionsStudioView.tsx +9 -11
  81. package/src/collection_editor/ui/collection_editor/GeneralSettingsForm.tsx +5 -2
  82. package/src/collection_editor/ui/collection_editor/properties/MapPropertyField.tsx +1 -1
  83. package/src/collection_editor/ui/collection_editor/properties/advanced/AdvancedPropertyValidation.tsx +5 -2
  84. package/src/collection_editor/ui/collection_editor/properties/conditions/ConditionsEditor.tsx +1 -0
  85. package/src/collection_editor/useJsonCollectionsConfigController.tsx +92 -84
  86. package/src/collection_editor/useLocalCollectionsConfigController.tsx +10 -3
  87. package/src/components/AdditionalFieldValue.tsx +68 -0
  88. package/src/components/ClearFilterSortButton.tsx +16 -7
  89. package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +7 -8
  90. package/src/components/CollectionTableBinding/PropertyTableCell.tsx +2 -2
  91. package/src/components/CollectionTableBinding/column_utils.tsx +15 -6
  92. package/src/components/CollectionTableBinding/internal/popup_field/PopupFormField.tsx +2 -2
  93. package/src/components/CollectionTableBinding/table_bindings.tsx +7 -1
  94. package/src/components/CollectionViewBinding/BoardCardBinding.tsx +26 -27
  95. package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +13 -7
  96. package/src/components/CollectionViewBinding/CollectionCardViewBinding.tsx +2 -1
  97. package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +822 -354
  98. package/src/components/CollectionViewBinding/CollectionViewActions.tsx +1 -1
  99. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +52 -12
  100. package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +9 -0
  101. package/src/components/CollectionViewBinding/EntityCardBinding.tsx +51 -39
  102. package/src/components/CollectionViewBinding/SearchExplanation.tsx +106 -0
  103. package/src/components/CollectionViewBinding/SearchHighlight.tsx +311 -0
  104. package/src/components/CollectionViewBinding/SlotValue.tsx +156 -0
  105. package/src/components/CollectionViewBinding/SortButton.tsx +16 -4
  106. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +23 -7
  107. package/src/components/CollectionViewBinding/SplitListShowButton.tsx +28 -0
  108. package/src/components/CollectionViewBinding/SplitListView.tsx +47 -7
  109. package/src/components/CollectionViewBinding/ViewModeToggle.tsx +10 -5
  110. package/src/components/CollectionViewBinding/useBoardDataController.tsx +23 -6
  111. package/src/components/CollectionViewBinding/usePreviewSlots.ts +183 -64
  112. package/src/components/CollectionViewBinding/utils.ts +7 -4
  113. package/src/components/DefaultDrawer.tsx +26 -23
  114. package/src/components/DetailViewBinding.tsx +109 -62
  115. package/src/components/DrawerNavigationGroup.tsx +16 -39
  116. package/src/components/DrawerNavigationItem.tsx +56 -16
  117. package/src/components/EditViewBinding.tsx +233 -50
  118. package/src/components/EntityDisplayHeader.tsx +170 -0
  119. package/src/components/EntityIdentityBar.tsx +163 -23
  120. package/src/components/EntityInspector.tsx +24 -6
  121. package/src/components/EntityPreviewBinding.tsx +58 -18
  122. package/src/components/EntityViewBinding.tsx +344 -41
  123. package/src/components/InlineEntityPreview.tsx +10 -10
  124. package/src/components/RebaseLayout.tsx +2 -2
  125. package/src/components/RebaseShell.tsx +2 -2
  126. package/src/components/ReferenceTable/SelectionTableBinding.tsx +4 -2
  127. package/src/components/RelationSelector.tsx +76 -15
  128. package/src/components/SearchIconsView.tsx +14 -33
  129. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +1 -0
  130. package/src/components/SideDialogs.tsx +15 -3
  131. package/src/components/SidePanelBinding.tsx +122 -66
  132. package/src/components/UserSelector.tsx +67 -13
  133. package/src/components/app/Scaffold.tsx +112 -10
  134. package/src/components/history/LastEditedByIndicator.tsx +7 -12
  135. package/src/components/index.ts +3 -0
  136. package/src/data_export/export/ExportCollectionAction.tsx +101 -48
  137. package/src/data_export/export/export.ts +63 -16
  138. package/src/data_export/export/fetch_export_data.ts +90 -0
  139. package/src/data_export/export/index.ts +1 -0
  140. package/src/data_import/components/DataNewPropertiesMapping.tsx +20 -3
  141. package/src/data_import/components/ImportNewPropertyFieldPreview.tsx +1 -0
  142. package/src/data_import/components/ImportSaveInProgress.tsx +35 -36
  143. package/src/data_import/import/ImportCollectionAction.tsx +13 -2
  144. package/src/data_import/utils/csv.ts +153 -0
  145. package/src/data_import/utils/data.ts +16 -2
  146. package/src/data_import/utils/file_to_json.ts +77 -6
  147. package/src/data_import/utils/index.ts +2 -0
  148. package/src/data_import/utils/save_entities.ts +138 -0
  149. package/src/data_import/utils/transforms.ts +23 -0
  150. package/src/editor/components/image-bubble.tsx +2 -0
  151. package/src/editor/selectors/link-selector.tsx +1 -0
  152. package/src/form/EntityForm.tsx +74 -53
  153. package/src/form/EntityFormBinding.tsx +24 -11
  154. package/src/form/PropertyFieldBinding.tsx +2 -2
  155. package/src/form/components/FieldBlock.tsx +80 -10
  156. package/src/form/components/FormRail.tsx +19 -2
  157. package/src/form/components/FormSections.tsx +32 -5
  158. package/src/form/components/StorageUploadProgress.tsx +9 -3
  159. package/src/form/components/useRailVisible.tsx +5 -4
  160. package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +2 -2
  161. package/src/form/field_bindings/BinaryFieldBinding.tsx +11 -5
  162. package/src/form/field_bindings/BlockFieldBinding.tsx +3 -3
  163. package/src/form/field_bindings/DateTimeFieldBinding.tsx +4 -0
  164. package/src/form/field_bindings/GeopointFieldBinding.tsx +23 -16
  165. package/src/form/field_bindings/KeyValueFieldBinding.tsx +3 -0
  166. package/src/form/field_bindings/MapFieldBinding.tsx +2 -2
  167. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +13 -7
  168. package/src/form/field_bindings/MultiSelectFieldBinding.tsx +7 -1
  169. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +21 -2
  170. package/src/form/field_bindings/ReferenceFieldBinding.tsx +1 -1
  171. package/src/form/field_bindings/RelationFieldBinding.tsx +5 -2
  172. package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -3
  173. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +2 -2
  174. package/src/form/field_bindings/TextFieldBinding.tsx +18 -0
  175. package/src/form/field_bindings/UserSelectFieldBinding.tsx +4 -1
  176. package/src/form/field_bindings/VectorFieldBinding.tsx +7 -4
  177. package/src/form/form_utils.ts +78 -0
  178. package/src/hooks/navigation/useBuildCollectionRegistryController.tsx +29 -5
  179. package/src/hooks/useEntityDisplay.tsx +262 -0
  180. package/src/hooks/useEntityDisplayTitle.tsx +27 -38
  181. package/src/index.ts +9 -1
  182. package/src/preview/PropertyPreview.tsx +25 -5
  183. package/src/preview/compact_text.ts +68 -0
  184. package/src/preview/components/BooleanPreview.tsx +5 -2
  185. package/src/preview/components/DatePreview.tsx +15 -2
  186. package/src/preview/components/ReferencePreview.tsx +5 -8
  187. package/src/preview/components/RelationPreview.tsx +8 -7
  188. package/src/preview/components/StorageThumbnail.tsx +5 -2
  189. package/src/preview/components/date_fns_locales.ts +125 -0
  190. package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +6 -3
  191. package/src/preview/property_previews/ArrayOfStringsPreview.tsx +26 -1
  192. package/src/preview/property_previews/ArrayOneOfPreview.tsx +10 -1
  193. package/src/preview/property_previews/ArrayPropertyPreview.tsx +10 -1
  194. package/src/preview/property_previews/MapPropertyPreview.tsx +52 -1
  195. package/src/preview/property_previews/NumberPropertyPreview.tsx +9 -1
  196. package/src/preview/property_previews/StringPropertyPreview.tsx +8 -5
  197. package/src/routes/RebaseRoute.tsx +62 -4
  198. package/src/types/components/PropertyPreviewProps.tsx +21 -0
  199. package/src/util/navigation_utils.ts +4 -2
  200. package/src/util/number_format.ts +46 -0
  201. package/src/util/previews.ts +8 -55
  202. package/src/util/property_utils.tsx +6 -1
  203. package/src/util/view_constants.ts +27 -0
  204. package/src/util/view_mode.ts +36 -0
  205. package/dist/CollectionEditorDialog-CjXkWZ64.js.map +0 -1
  206. package/dist/PropertyEditView-W8I3YV89.js.map +0 -1
  207. package/dist/RouterCollectionsStudioView-BsLTNoqX.js.map +0 -1
  208. package/dist/export-J79rMoNj.js.map +0 -1
  209. package/dist/import-BvYl--U4.js.map +0 -1
  210. package/dist/util-GiwPgxnL.js.map +0 -1
@@ -3,30 +3,39 @@ 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
- adminMenuOpen,
11
- tooltipsOpen,
12
- url,
13
- onClick,
14
- indented = false
15
- }: {
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 = {
16
11
  icon: React.ReactElement,
17
12
  name: string,
18
- tooltipsOpen: boolean,
13
+ /** @deprecated No longer read: the tooltip is uncontrolled. */
14
+ tooltipsOpen?: boolean,
19
15
  drawerOpen: boolean,
16
+ /** @deprecated No longer read: the tooltip is uncontrolled. */
20
17
  adminMenuOpen?: boolean,
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
@@ -40,9 +49,30 @@ export function DrawerNavigationItem({
40
49
  {icon}
41
50
  </div>;
42
51
 
52
+ const [wantsTooltip, setWantsTooltip] = React.useState(false);
53
+
54
+ // Radix will not report a close it never noticed: while the tooltip is masked
55
+ // the pointer can leave the row without `onOpenChange(false)` ever firing. So
56
+ // the stale `true` is dropped on the way *into* the masked state — otherwise
57
+ // it surfaces the moment the mask lifts, as a tooltip hanging over a rail the
58
+ // pointer left long ago.
59
+ if (drawerOpen && wantsTooltip) setWantsTooltip(false);
60
+
43
61
  const listItem = <div>
44
62
  <NavLink
45
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}
46
76
  style={{
47
77
  width: "100%",
48
78
  transition: drawerOpen ? "width 150ms ease-in" : undefined
@@ -70,8 +100,18 @@ export function DrawerNavigationItem({
70
100
  </NavLink>
71
101
  </div>;
72
102
 
103
+ // Per row, and always controlled. The old shared flag could only say "every
104
+ // tooltip" or "none", and the state it needed was unreachable, so no entry
105
+ // tooltip ever opened — leaving a bare rail of unlabelled icons. Radix decides
106
+ // when this one wants to be open, from the pointer and from focus; the drawer
107
+ // only masks it once the label says the same thing.
108
+ //
109
+ // Masked rather than withheld: dropping `open`, or the title, would move the
110
+ // tooltip between controlled and uncontrolled as the drawer opens, which
111
+ // strands the old state — a tooltip left up over a pointer nowhere near it.
73
112
  return <Tooltip
74
- open={drawerOpen || adminMenuOpen ? false : tooltipsOpen}
113
+ open={!drawerOpen && wantsTooltip}
114
+ onOpenChange={setWantsTooltip}
75
115
  side="right"
76
116
  title={name}>
77
117
  {listItem}
@@ -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";
@@ -53,10 +55,17 @@ import { useEntityDisplayTitle } from "../hooks/useEntityDisplayTitle";
53
55
  import { createFormexStub, getEntityFromCache } from "@rebasepro/app";
54
56
  import { usePermissions } from "@rebasepro/app";
55
57
  import { useUrlController } from "../hooks/navigation/contexts/UrlContext";
56
- import { withViewMode } from "../util/view_mode";
58
+ import { withListState } from "../util/view_mode";
57
59
  import { useNavigate } from "react-router";
58
60
 
59
- 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";
60
69
  export { MAIN_TAB_VALUE, JSON_TAB_VALUE, HISTORY_TAB_VALUE };
61
70
 
62
71
  export type BarActionsParams = {
@@ -64,6 +73,19 @@ export type BarActionsParams = {
64
73
  status: EntityStatus,
65
74
  path: string,
66
75
  entityId?: string | number;
76
+ /** Whether `values` differ from what is stored. */
77
+ dirty: boolean;
78
+ /**
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.
87
+ */
88
+ carryEdit?: () => void;
67
89
  };
68
90
 
69
91
  export type OnTabChangeParams<M extends Record<string, unknown>> = {
@@ -89,6 +111,16 @@ export interface EditViewBindingProps<M extends Record<string, unknown> = Record
89
111
  onSaved?: (params: OnUpdateParams) => void;
90
112
  onTabChange?: (props: OnTabChangeParams<M>) => void;
91
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;
92
124
  layout?: "side_panel" | "full_screen" | "split" | "dialog";
93
125
  barActions?: (params: BarActionsParams) => React.ReactNode;
94
126
  /**
@@ -96,6 +128,12 @@ export interface EditViewBindingProps<M extends Record<string, unknown> = Record
96
128
  * of the breadcrumb — the side panel's close button.
97
129
  */
98
130
  barActionsStart?: React.ReactNode;
131
+ /**
132
+ * Full screen only: bring the list back beside this record. Set when the
133
+ * record was opened out of a split collection, so folding the list away is
134
+ * reversible; it takes the place of the back arrow when present.
135
+ */
136
+ onShowList?: () => void;
99
137
  formProps?: Partial<EntityFormBindingProps<M>>,
100
138
  /**
101
139
  * Pre-populate the form with these values when creating a new entity.
@@ -127,9 +165,39 @@ export function EditViewBinding<M extends Record<string, unknown>>({
127
165
  useCache: false
128
166
  });
129
167
 
130
- const initialDirtyValues = entityId
131
- ? getEntityFromMemoryCache(props.path + "/" + entityId)
132
- : (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;
133
201
 
134
202
  const { canEdit: canEditHook } = usePermissions();
135
203
 
@@ -186,12 +254,14 @@ export function EditViewBindingInner<M extends Record<string, unknown>>({
186
254
  onSaved,
187
255
  onTabChange,
188
256
  navigateBack,
257
+ onCloseRequest,
189
258
  entity,
190
259
  initialDirtyValues,
191
260
  dataLoading,
192
261
  layout = "side_panel",
193
262
  barActions,
194
263
  barActionsStart,
264
+ onShowList,
195
265
  status,
196
266
  setStatus,
197
267
  formProps,
@@ -271,7 +341,9 @@ parentEntityIds,
271
341
  }
272
342
  }, [selectedTabProp, defaultSelectedView]);
273
343
 
274
- 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);
275
347
  const subcollections = childViews.map(v => v.collection);
276
348
  const subcollectionsCount = subcollections?.length ?? 0;
277
349
  const customViews = collection.entityViews ?? [];
@@ -405,8 +477,8 @@ parentEntityIds,
405
477
 
406
478
  const displayTitle = useEntityDisplayTitle({
407
479
  collection,
480
+ entity: usedEntity,
408
481
  values: (formContext?.values ?? usedEntity?.values) as Record<string, unknown> | undefined,
409
- entityId,
410
482
  status
411
483
  });
412
484
 
@@ -420,6 +492,35 @@ parentEntityIds,
420
492
  || (status === "existing" && !formex?.dirty)
421
493
  );
422
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
+
423
524
  const [inspectorTab, setInspectorTab] = useState<InspectorTab | null>(null);
424
525
  // A save adds a revision, and the inspector can be open while it happens:
425
526
  // Save lives in the identity bar, above the panel. Counting saves gives the
@@ -429,7 +530,26 @@ parentEntityIds,
429
530
  /* ---- record actions, in the bar's overflow menu ---------------------- */
430
531
 
431
532
  const sideDialogContext = useSideDialogContext();
432
- 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));
433
553
 
434
554
  const recordActions = useRecordActions({
435
555
  collection,
@@ -524,35 +644,40 @@ parentEntityIds,
524
644
  const FormViewBuilder = formViewConfig?.Builder ? resolveComponentRef<EntityCustomViewParams>(formViewConfig.Builder as ComponentRef<EntityCustomViewParams>) : null;
525
645
  const formViewIncludeActions = formViewConfig?.includeActions !== false;
526
646
 
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;
647
+ // Without edit permission the record is rendered read-only — the same
648
+ // sections, spans and rail as the form, so losing the permission changes the
649
+ // controls and nothing else. The `h4` that used to head this repeated the
650
+ // collection name the identity bar above it already carries.
651
+ const entityReadOnlyView = !canEdit && entity ? (
652
+ FormViewBuilder && readOnlyFormContext
653
+ ? <div className={cls(
654
+ "@container/form flex-1 flex flex-row w-full overflow-y-auto justify-center",
655
+ (canEdit || !mainViewVisible || selectedSecondaryForm) ? "hidden" : ""
656
+ )}>
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"}>
659
+ <ErrorBoundary>
660
+ <Suspense fallback={<CircularProgressCenter />}>
661
+ <FormViewBuilder
662
+ collection={collection}
663
+ parentCollectionSlugs={parentCollectionSlugs} parentEntityIds={parentEntityIds}
664
+ entity={usedEntity}
665
+ modifiedValues={usedEntity?.values}
666
+ formContext={readOnlyFormContext as FormContext<Record<string, unknown>>}
667
+ />
668
+ </Suspense>
669
+ </ErrorBoundary>
670
+ </div>
671
+ </div>
672
+ : <EntityViewBinding
673
+ className={cls((canEdit || !mainViewVisible || selectedSecondaryForm) ? "hidden" : "")}
674
+ entity={entity}
675
+ path={path}
676
+ collection={collection}
677
+ asPage
678
+ openEntityMode={layout}
679
+ formContext={readOnlyFormContext}/>
680
+ ) : null;
556
681
 
557
682
  const entityView = FormViewBuilder ? (
558
683
  // formView.Builder replaces the default form
@@ -609,8 +734,32 @@ parentEntityIds,
609
734
  ...params,
610
735
  selectedTab: MAIN_TAB_VALUE === activeTab ? undefined : activeTab
611
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.
612
742
  onSaved?.(res);
613
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
+ }
614
763
  }}
615
764
  Builder={resolveComponentRef(selectedSecondaryForm?.Builder as ComponentRef<EntityCustomViewParams<M>> | undefined) as React.ComponentType<EntityCustomViewParams<M>> | undefined}
616
765
  />
@@ -620,7 +769,7 @@ parentEntityIds,
620
769
  const icon = getIcon(subcollection.icon, undefined, undefined, "smallest");
621
770
  return (
622
771
  <Tab
623
- className="text-sm min-w-[90px]"
772
+ className={cls(ENTITY_VIEW_TAB_CLASS, ENTITY_VIEW_TAB_LABEL_CLASS)}
624
773
  value={subcollection.slug}
625
774
  key={`entity_detail_collection_tab_${subcollection.name}`}>
626
775
  <span className="flex items-center gap-1.5">
@@ -636,7 +785,7 @@ parentEntityIds,
636
785
  const icon = getIcon(view.icon, undefined, undefined, "smallest");
637
786
  return (
638
787
  <Tab
639
- className={!view.tabComponent ? "text-sm min-w-[90px]" : undefined}
788
+ className={cls(ENTITY_VIEW_TAB_CLASS, !view.tabComponent && ENTITY_VIEW_TAB_LABEL_CLASS)}
640
789
  value={view.key}
641
790
  key={`entity_detail_collection_tab_${view.name}`}>
642
791
  {view.tabComponent ?? (
@@ -653,7 +802,7 @@ parentEntityIds,
653
802
  const icon = getIcon(view.icon, undefined, undefined, "smallest");
654
803
  return (
655
804
  <Tab
656
- className={!view.tabComponent ? "text-sm min-w-[90px]" : undefined}
805
+ className={cls(ENTITY_VIEW_TAB_CLASS, !view.tabComponent && ENTITY_VIEW_TAB_LABEL_CLASS)}
657
806
  value={view.key}
658
807
  key={`entity_detail_collection_tab_${view.name}`}>
659
808
  {view.tabComponent ?? (
@@ -673,6 +822,7 @@ parentEntityIds,
673
822
  <IconButton
674
823
  size="small"
675
824
  onClick={() => {
825
+ carryEdit();
676
826
  const editSuffix = collection.defaultEntityAction === "view" ? "/edit" : "";
677
827
  const entityUrl = urlController.buildUrlCollectionPath(`${path}/${entityId}${editSuffix}`);
678
828
  navigate(`${entityUrl}#full`);
@@ -687,6 +837,9 @@ parentEntityIds,
687
837
 
688
838
  <EntityIdentityBar
689
839
  collection={collection as AdminCollection}
840
+ collectionUrl={layout === "full_screen" || layout === "split"
841
+ ? withListState(urlController.buildUrlCollectionPath(path))
842
+ : undefined}
690
843
  title={displayTitle}
691
844
  entityId={status === "existing" ? entityId : undefined}
692
845
  status={status}
@@ -696,29 +849,55 @@ parentEntityIds,
696
849
  saveDisabled={!canEdit || saveDisabled}
697
850
  // Full screen replaces the collection entirely, leaving browser Back
698
851
  // 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"
701
- ? () => navigate(withViewMode(urlController.buildUrlCollectionPath(path)))
852
+ // for that reason; the edit view is reached the same way. Where the
853
+ // list can be unfolded back beside the record, that chevron reaches
854
+ // the same collection and the arrow beside it is noise.
855
+ onBack={layout === "full_screen" && !onShowList
856
+ ? () => navigate(withListState(urlController.buildUrlCollectionPath(path)))
702
857
  : undefined}
703
858
  onSave={canEdit && formContext ? () => {
704
859
  sideDialogContext.setPendingClose?.(false);
860
+ pendingCloseRef.current = false;
705
861
  formContext.submit();
706
862
  } : undefined}
707
863
  onSaveAndClose={canEdit && formContext && canCloseAfterSave ? () => {
864
+ // Lowered again once the submit settles. A submit the form
865
+ // rejects never reaches `onSaved`, so nothing would consume the
866
+ // flag and the *next* save — a keyboard ⌘S, say — would close
867
+ // the panel out from under an edit nobody asked to finish. A
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
+ }
708
875
  sideDialogContext.setPendingClose?.(true);
709
- formContext.submit();
876
+ Promise.resolve(formContext.submit())
877
+ .finally(() => sideDialogContext.setPendingClose?.(false));
710
878
  } : undefined}
879
+ // Welded to Save in the split, its own button in the overlays.
880
+ saveAndClosePlacement={layout === "split" ? "menu" : "button"}
881
+ onClose={onCloseRequest}
711
882
  onDiscard={canEdit && formContext ? () => formContext.formex.resetForm() : undefined}
712
883
  onInspect={includeJsonView ? () => setInspectorTab("json") : undefined}
713
884
  onViewHistory={includeHistoryView ? () => setInspectorTab("history") : undefined}
885
+ externalLink={usedEntity
886
+ ? customizationController?.entityLinkBuilder?.({ entity: usedEntity })
887
+ : undefined}
714
888
  recordActions={recordActionItems}
715
889
  pluginActions={formPluginActions}
716
890
  leading={<>
717
891
  {barActionsStart}
718
892
  {/* Split view: closing the list is opening this record
719
893
  full screen, and the control for it belongs at this
720
- bar's leading edge. */}
721
- {layout === "split" && <SplitListCloseButton/>}
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
+ }}/>}
722
901
  </>}
723
902
  trailing={<>
724
903
  {pluginActionsTop}
@@ -727,7 +906,9 @@ parentEntityIds,
727
906
  path,
728
907
  entityId,
729
908
  values: formContext?.values ?? usedEntity?.values ?? {},
730
- status
909
+ status,
910
+ dirty: Boolean(formContext?.formex?.dirty),
911
+ carryEdit
731
912
  })}
732
913
  </>}
733
914
  />
@@ -737,10 +918,11 @@ parentEntityIds,
737
918
  disappears entirely when there is nowhere else to go. */}
738
919
  {hasAdditionalViews && <div className={cls(
739
920
  "h-10 shrink-0 flex items-stretch border-b px-2 min-w-0",
921
+ ENTITY_VIEW_TAB_BAR_CLASS,
740
922
  defaultBorderMixin
741
923
  )}>
742
924
  <Tabs
743
- className={"!w-full"}
925
+ className={"!w-full items-stretch"}
744
926
  innerClassName={"h-full"}
745
927
  variant={"boxy"}
746
928
  value={activeTab}
@@ -748,7 +930,8 @@ parentEntityIds,
748
930
  onSideTabClick(value);
749
931
  }}>
750
932
 
751
- <Tab value={MAIN_TAB_VALUE}>
933
+ <Tab value={MAIN_TAB_VALUE}
934
+ className={cls(ENTITY_VIEW_TAB_CLASS, ENTITY_VIEW_TAB_LABEL_CLASS)}>
752
935
  <span className="flex items-center gap-1.5">
753
936
  {getIcon(collection.icon, undefined, undefined, "smallest")}
754
937
  {collection.singularName ?? collection.name}