@rebasepro/admin 0.12.1-canary.gf5f1d39 → 0.13.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 (178) hide show
  1. package/README.md +1 -1
  2. package/dist/{CollectionEditorDialog-Dpc75wIZ.js → CollectionEditorDialog-CjXkWZ64.js} +75 -62
  3. package/dist/{CollectionEditorDialog-Dpc75wIZ.js.map → CollectionEditorDialog-CjXkWZ64.js.map} +1 -1
  4. package/dist/{PropertyEditView-DH3XZC2x.js → PropertyEditView-W8I3YV89.js} +36 -16
  5. package/dist/{PropertyEditView-DH3XZC2x.js.map → PropertyEditView-W8I3YV89.js.map} +1 -1
  6. package/dist/{RouterCollectionsStudioView-BZLq4MGp.js → RouterCollectionsStudioView-BsLTNoqX.js} +5 -5
  7. package/dist/{RouterCollectionsStudioView-BZLq4MGp.js.map → RouterCollectionsStudioView-BsLTNoqX.js.map} +1 -1
  8. package/dist/collection_editor/serializable_types.d.ts +4 -1
  9. package/dist/collection_editor/ui/collection_editor/CollectionRelationsTab.d.ts +1 -0
  10. package/dist/collection_editor/useLocalCollectionsConfigController.d.ts +8 -0
  11. package/dist/collection_editor_ui.js +5 -5
  12. package/dist/collection_editor_ui.js.map +1 -1
  13. package/dist/components/CollectionTableBinding/column_utils.d.ts +17 -0
  14. package/dist/components/CollectionViewBinding/CollectionViewStartActions.d.ts +7 -2
  15. package/dist/components/CollectionViewBinding/SortButton.d.ts +24 -0
  16. package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +10 -0
  17. package/dist/components/CollectionViewBinding/SplitViewContext.d.ts +23 -0
  18. package/dist/components/CollectionViewBinding/hooks/useKanbanDragAndDrop.d.ts +26 -0
  19. package/dist/components/DetailViewBinding.d.ts +5 -0
  20. package/dist/components/EditFormActions.d.ts +12 -1
  21. package/dist/components/EditViewBinding.d.ts +6 -1
  22. package/dist/components/EntityIdentityBar.d.ts +53 -0
  23. package/dist/components/EntityInspector.d.ts +35 -0
  24. package/dist/components/EntityPreviewNesting.d.ts +25 -0
  25. package/dist/components/EntityViewBinding.d.ts +9 -0
  26. package/dist/components/InlineEntityPreview.d.ts +47 -0
  27. package/dist/components/history/EntityHistoryView.d.ts +4 -1
  28. package/dist/components/index.d.ts +2 -0
  29. package/dist/editor.js +18 -10
  30. package/dist/editor.js.map +1 -1
  31. package/dist/{export-BzIpUdSK.js → export-J79rMoNj.js} +2 -2
  32. package/dist/{export-BzIpUdSK.js.map → export-J79rMoNj.js.map} +1 -1
  33. package/dist/form/EntityForm.d.ts +1 -1
  34. package/dist/form/EntityFormActions.d.ts +11 -1
  35. package/dist/form/PropertyFieldBinding.d.ts +1 -1
  36. package/dist/form/components/FieldBlock.d.ts +30 -0
  37. package/dist/form/components/FormRail.d.ts +18 -0
  38. package/dist/form/components/FormSections.d.ts +16 -0
  39. package/dist/form/components/index.d.ts +3 -2
  40. package/dist/form/components/useRailVisible.d.ts +24 -0
  41. package/dist/form/field_bindings/BinaryFieldBinding.d.ts +1 -1
  42. package/dist/form/field_bindings/DateTimeFieldBinding.d.ts +1 -1
  43. package/dist/form/field_bindings/GeopointFieldBinding.d.ts +1 -1
  44. package/dist/form/field_bindings/MarkdownEditorFieldBinding.d.ts +1 -1
  45. package/dist/form/field_bindings/MultiSelectFieldBinding.d.ts +1 -1
  46. package/dist/form/field_bindings/ReadOnlyFieldBinding.d.ts +1 -1
  47. package/dist/form/field_bindings/SelectFieldBinding.d.ts +1 -1
  48. package/dist/form/field_bindings/StorageUploadFieldBinding.d.ts +1 -1
  49. package/dist/form/field_bindings/SwitchFieldBinding.d.ts +1 -1
  50. package/dist/form/field_bindings/TextFieldBinding.d.ts +1 -1
  51. package/dist/form/field_bindings/UserSelectFieldBinding.d.ts +1 -1
  52. package/dist/form/field_bindings/VectorFieldBinding.d.ts +1 -1
  53. package/dist/{history-g9688IaK.js → history-D7L_JlLO.js} +35 -33
  54. package/dist/history-D7L_JlLO.js.map +1 -0
  55. package/dist/hooks/navigation/utils.d.ts +16 -0
  56. package/dist/hooks/useBuildSidePanel.d.ts +14 -0
  57. package/dist/hooks/useEntityDisplayTitle.d.ts +18 -0
  58. package/dist/hooks/useHistory.d.ts +7 -0
  59. package/dist/hooks/useRecordActions.d.ts +17 -0
  60. package/dist/{import-BjUG-cIY.js → import-BvYl--U4.js} +2 -2
  61. package/dist/{import-BjUG-cIY.js.map → import-BvYl--U4.js.map} +1 -1
  62. package/dist/index.js +106 -35
  63. package/dist/index.js.map +1 -1
  64. package/dist/markdown-CklalUUk.js.map +1 -1
  65. package/dist/preview/components/InlineEntityListPreview.d.ts +15 -0
  66. package/dist/preview/index.d.ts +1 -0
  67. package/dist/preview/property_previews/ArrayOfReferencesPreview.d.ts +1 -1
  68. package/dist/preview/property_previews/ArrayOfRelationsPreview.d.ts +1 -1
  69. package/dist/types/components/EntityFormActionsProps.d.ts +0 -1
  70. package/dist/types/components/EntityFormProps.d.ts +0 -5
  71. package/dist/types/fields.d.ts +24 -0
  72. package/dist/{util-B5fJm1FA.js → util-GiwPgxnL.js} +2106 -1117
  73. package/dist/util-GiwPgxnL.js.map +1 -0
  74. package/package.json +32 -34
  75. package/src/collection_editor/ConfigControllerProvider.tsx +1 -1
  76. package/src/collection_editor/serializable_types.ts +4 -3
  77. package/src/collection_editor/serializable_utils.ts +51 -17
  78. package/src/collection_editor/ui/collection_editor/CollectionEditorDialog.tsx +65 -22
  79. package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +38 -30
  80. package/src/collection_editor/ui/collection_editor/CollectionRelationsTab.tsx +3 -1
  81. package/src/collection_editor/ui/collection_editor/RouterCollectionStudioView.tsx +1 -1
  82. package/src/collection_editor/ui/collection_editor/RouterCollectionsStudioView.tsx +1 -1
  83. package/src/collection_editor/ui/collection_editor/properties/RelationPropertyField.tsx +16 -0
  84. package/src/collection_editor/useLocalCollectionsConfigController.tsx +127 -15
  85. package/src/collection_editor/validateCollectionJson.ts +3 -3
  86. package/src/components/AdminModeSyncer.tsx +1 -1
  87. package/src/components/CollectionTableBinding/PropertyTableCell.tsx +10 -2
  88. package/src/components/CollectionTableBinding/column_utils.tsx +59 -2
  89. package/src/components/CollectionTableBinding/table_bindings.tsx +2 -1
  90. package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +23 -16
  91. package/src/components/CollectionViewBinding/CollectionViewActions.tsx +7 -1
  92. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +9 -2
  93. package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +47 -12
  94. package/src/components/CollectionViewBinding/SortButton.tsx +189 -0
  95. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +33 -0
  96. package/src/components/CollectionViewBinding/SplitListView.tsx +18 -3
  97. package/src/components/CollectionViewBinding/SplitViewContext.tsx +34 -0
  98. package/src/components/CollectionViewBinding/hooks/useKanbanDragAndDrop.ts +48 -9
  99. package/src/components/CollectionViewBinding/useBoardDataController.tsx +80 -25
  100. package/src/components/CollectionViewBinding/usePreviewSlots.ts +18 -6
  101. package/src/components/DefaultAppBar.tsx +1 -1
  102. package/src/components/DefaultDrawer.tsx +1 -1
  103. package/src/components/DetailViewBinding.tsx +112 -148
  104. package/src/components/DrawerNavigationItem.tsx +1 -1
  105. package/src/components/EditFormActions.tsx +54 -129
  106. package/src/components/EditViewBinding.tsx +202 -99
  107. package/src/components/EntityIdentityBar.tsx +288 -0
  108. package/src/components/EntityInspector.tsx +137 -0
  109. package/src/components/EntityPreviewBinding.tsx +6 -2
  110. package/src/components/EntityPreviewNesting.tsx +50 -0
  111. package/src/components/EntityViewBinding.tsx +35 -32
  112. package/src/components/HomePage/FavouritesView.tsx +1 -1
  113. package/src/components/HomePage/NavigationCardBinding.tsx +1 -1
  114. package/src/components/HomePage/SmallNavigationCard.tsx +1 -1
  115. package/src/components/InlineEntityPreview.tsx +149 -0
  116. package/src/components/RebaseLayout.tsx +1 -1
  117. package/src/components/RebaseNavigation.tsx +45 -5
  118. package/src/components/RebaseRouteDefs.tsx +1 -1
  119. package/src/components/SelectableTable/SelectableTable.tsx +2 -2
  120. package/src/components/SidePanelBinding.tsx +19 -13
  121. package/src/components/UserSelector.tsx +12 -6
  122. package/src/components/common/default_entity_actions.tsx +4 -3
  123. package/src/components/history/EntityHistoryEntry.tsx +28 -7
  124. package/src/components/history/EntityHistoryView.tsx +29 -24
  125. package/src/components/history/LastEditedByIndicator.tsx +4 -3
  126. package/src/components/index.ts +2 -0
  127. package/src/data_import/utils/get_import_inference_type.ts +6 -0
  128. package/src/form/EntityForm.tsx +267 -179
  129. package/src/form/EntityFormActions.tsx +50 -99
  130. package/src/form/EntityFormBinding.tsx +4 -2
  131. package/src/form/PropertyFieldBinding.tsx +4 -0
  132. package/src/form/components/FieldBlock.tsx +119 -0
  133. package/src/form/components/FormRail.tsx +125 -0
  134. package/src/form/components/FormSections.tsx +118 -0
  135. package/src/form/components/index.tsx +3 -2
  136. package/src/form/components/useRailVisible.tsx +46 -0
  137. package/src/form/field_bindings/BinaryFieldBinding.tsx +3 -2
  138. package/src/form/field_bindings/DateTimeFieldBinding.tsx +3 -2
  139. package/src/form/field_bindings/GeopointFieldBinding.tsx +3 -2
  140. package/src/form/field_bindings/MapFieldBinding.tsx +1 -2
  141. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +3 -2
  142. package/src/form/field_bindings/MultiSelectFieldBinding.tsx +2 -1
  143. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +2 -1
  144. package/src/form/field_bindings/ReferenceFieldBinding.tsx +2 -1
  145. package/src/form/field_bindings/RelationFieldBinding.tsx +6 -4
  146. package/src/form/field_bindings/RepeatFieldBinding.tsx +4 -1
  147. package/src/form/field_bindings/SelectFieldBinding.tsx +3 -2
  148. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +31 -8
  149. package/src/form/field_bindings/SwitchFieldBinding.tsx +7 -3
  150. package/src/form/field_bindings/TextFieldBinding.tsx +18 -16
  151. package/src/form/field_bindings/UserSelectFieldBinding.tsx +3 -2
  152. package/src/form/field_bindings/VectorFieldBinding.tsx +2 -1
  153. package/src/hooks/navigation/useBuildUrlController.tsx +1 -1
  154. package/src/hooks/navigation/useTopLevelNavigation.ts +18 -7
  155. package/src/hooks/navigation/utils.ts +20 -0
  156. package/src/hooks/useBuildSideDialogsController.tsx +2 -2
  157. package/src/hooks/useBuildSidePanel.tsx +22 -61
  158. package/src/hooks/useEntityDisplayTitle.tsx +60 -0
  159. package/src/hooks/useHistory.ts +20 -10
  160. package/src/hooks/useRecordActions.tsx +69 -0
  161. package/src/hooks/useResolvedUsers.ts +10 -5
  162. package/src/preview/PropertyPreview.tsx +48 -22
  163. package/src/preview/components/InlineEntityListPreview.tsx +42 -0
  164. package/src/preview/components/ReferencePreview.tsx +34 -14
  165. package/src/preview/components/RelationPreview.tsx +34 -14
  166. package/src/preview/index.ts +1 -0
  167. package/src/preview/property_previews/ArrayOfReferencesPreview.tsx +24 -1
  168. package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +25 -1
  169. package/src/routes/RebaseRoute.tsx +2 -2
  170. package/src/types/components/EntityFormActionsProps.tsx +0 -1
  171. package/src/types/components/EntityFormProps.tsx +0 -6
  172. package/src/types/fields.tsx +27 -0
  173. package/dist/form/components/FormEntry.d.ts +0 -6
  174. package/dist/form/components/FormLayout.d.ts +0 -5
  175. package/dist/history-g9688IaK.js.map +0 -1
  176. package/dist/util-B5fJm1FA.js.map +0 -1
  177. package/src/form/components/FormEntry.tsx +0 -22
  178. package/src/form/components/FormLayout.tsx +0 -16
@@ -1,7 +1,7 @@
1
1
 
2
2
  import React, { useEffect, useState, useCallback } from "react";
3
3
  ;
4
- import { useApiConfig } from "@rebasepro/app";
4
+ import { useApiBase, useApiConfig } from "@rebasepro/app";
5
5
  import { useAuthController } from "@rebasepro/app";
6
6
  import { HistoryEntryData } from "../../hooks";
7
7
  import type { AdminCollection } from "@rebasepro/admin-types";
@@ -35,6 +35,7 @@ export function LastEditedByIndicator({
35
35
  collection: AdminCollection;
36
36
  }) {
37
37
  const apiConfig = useApiConfig();
38
+ const apiBase = useApiBase();
38
39
  const authController = useAuthController();
39
40
  const [latestEntry, setLatestEntry] = useState<HistoryEntryData | undefined>();
40
41
 
@@ -46,7 +47,7 @@ export function LastEditedByIndicator({
46
47
  const headers: Record<string, string> = {};
47
48
  if (token) headers["Authorization"] = `Bearer ${token}`;
48
49
 
49
- const url = `${apiConfig.apiUrl}/api/data/${collection.slug}/${entityId}/history?limit=1&offset=0`;
50
+ const url = `${apiBase}/data/${collection.slug}/${entityId}/history?limit=1&offset=0`;
50
51
  const response = await fetch(url, { headers });
51
52
 
52
53
  if (response.ok) {
@@ -59,7 +60,7 @@ export function LastEditedByIndicator({
59
60
  // Silently fail — this is a non-critical UI indicator
60
61
  console.debug("Failed to fetch latest history entry:", error);
61
62
  }
62
- }, [apiConfig, entityId, collection.slug]);
63
+ }, [apiConfig, apiBase, entityId, collection.slug]);
63
64
 
64
65
  useEffect(() => {
65
66
  fetchLatest();
@@ -25,6 +25,8 @@ export * from "./ReferenceWidget";
25
25
  export * from "./SearchIconsView";
26
26
  export * from "./FieldCaption";
27
27
  export * from "./EntityPreviewBinding";
28
+ export * from "./EntityPreviewNesting";
29
+ export * from "./InlineEntityPreview";
28
30
 
29
31
  // history is lazy-loaded by EditViewBinding and resolutions.ts
30
32
  // export * from "./history";
@@ -1,6 +1,12 @@
1
1
  import { DataType } from "@rebasepro/types";
2
2
 
3
3
  export function getInferenceType(value: any): DataType {
4
+ // `typeof null === "object"`, so a null or empty CSV cell used to fall past
5
+ // every check and land on "map" — an empty cell described as a nested object.
6
+ // `inferTypeFromValue` in @rebasepro/inference already answers "string" here;
7
+ // the two entry points into inference have to agree.
8
+ if (value === null || value === undefined)
9
+ return "string";
4
10
  if (typeof value === "number")
5
11
  return "number";
6
12
  else if (typeof value === "string")
@@ -1,24 +1,25 @@
1
1
 
2
- import type { EntityCustomViewParams, AdditionalFieldDelegateProps, AdminCollection } from "@rebasepro/admin-types";
2
+ import type { AdditionalFieldDelegateProps } from "@rebasepro/admin-types";
3
3
  import type { FormContext, PropertyFieldBindingProps } from "../types/fields";
4
- import type { PropertyConfig } from "@rebasepro/admin-types";
5
4
  import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
5
  import { Entity, EntityStatus, EntityValues } from "@rebasepro/types";
7
- import type { EntityFormProps, OnUpdateParams } from "../types/components/EntityFormProps";
6
+ import type { EntityFormProps } from "../types/components/EntityFormProps";
8
7
  import { deepEqual as equal } from "fast-equals";
9
8
 
10
9
  import { ErrorBoundary } from "@rebasepro/ui";
11
- import { AlignLeftIcon, CheckIcon, LoaderIcon, PencilIcon, useDebouncedCallback } from "@rebasepro/ui";
10
+ import { AlignLeftIcon, useDebouncedCallback } from "@rebasepro/ui";
12
11
  import { getDefaultValuesFor } from "@rebasepro/common";
13
- import { isHidden, isReadOnly } from "@rebasepro/app";
12
+ import { isReadOnly } from "@rebasepro/app";
14
13
 
15
- import { useCustomizationController } from "@rebasepro/app";
16
- import { getFormFieldKeys } from "@rebasepro/app";
17
- import { Alert, Button, Chip, cls, Dialog, DialogActions, DialogContent, DialogTitle, iconSize, paperMixin, Tooltip, Typography } from "@rebasepro/ui";
14
+ import { getFormFieldKeys, resolveFormLayout } from "@rebasepro/app";
15
+ import type { ResolvedFormField } from "@rebasepro/app";
16
+ import { Alert, Button, cls, Dialog, DialogActions, DialogContent, DialogTitle, iconSize, paperMixin, Typography } from "@rebasepro/ui";
18
17
  import { Formex, FormexController, useCreateFormex } from "@rebasepro/forms";
19
18
 
20
- import { FormEntry } from "./components/FormEntry";
21
- import { FormLayout } from "./components/FormLayout";
19
+ import { FieldBlock, isSelfLabellingProperty } from "./components/FieldBlock";
20
+ import { FormRail } from "./components/FormRail";
21
+ import { FormSections } from "./components/FormSections";
22
+ import { useRailVisible } from "./components/useRailVisible";
22
23
  import { LabelWithIconAndTooltip } from "./components/LabelWithIconAndTooltip";
23
24
  import { PropertyFieldBinding } from "./PropertyFieldBinding";
24
25
  import { flattenKeys } from "@rebasepro/app";
@@ -28,9 +29,7 @@ import { EntityFormActions } from "./EntityFormActions";
28
29
  import type { EntityFormActionsProps } from "../types/components/EntityFormActionsProps";
29
30
  import { LocalChangesMenu } from "./components/LocalChangesMenu";
30
31
 
31
- import { getEntityTitlePropertyKeyForEntity, isUserSelectProperty, resolveTitleToString } from "../util/previews";
32
- import { getUserLabel, useResolvedUser } from "../hooks/useResolvedUsers";
33
- import { getValueInPath, mergeDeep } from "@rebasepro/utils";
32
+ import { mergeDeep } from "@rebasepro/utils";
34
33
  import {
35
34
  getChanges,
36
35
  zodToFormErrors
@@ -55,7 +54,6 @@ export function EntityForm<M extends Record<string, unknown>>({
55
54
  entity,
56
55
  initialDirtyValues,
57
56
  onFormContextReady,
58
- forceActionsAtTheBottom,
59
57
  initialStatus,
60
58
  className,
61
59
  onStatusChange,
@@ -66,7 +64,6 @@ export function EntityForm<M extends Record<string, unknown>>({
66
64
  Builder,
67
65
  EntityFormActionsComponent = EntityFormActions,
68
66
  showDefaultActions = true,
69
- showEntityPath = true,
70
67
  navigateBack: navigateBackProp,
71
68
  children,
72
69
  // Headless callback props
@@ -87,8 +84,6 @@ export function EntityForm<M extends Record<string, unknown>>({
87
84
  onClearLocalChanges
88
85
  }: EntityFormProps<M>) {
89
86
 
90
- const customizationController = useCustomizationController();
91
-
92
87
  const navigateBack = useCallback(() => {
93
88
  if (navigateBackProp) {
94
89
  navigateBackProp();
@@ -122,7 +117,6 @@ export function EntityForm<M extends Record<string, unknown>>({
122
117
  }, [entityIdProp, status]);
123
118
 
124
119
  const [entityId, setEntityId] = useState<string | number | undefined>(initialEntityId);
125
- const [entityIdError, setEntityIdError] = useState<boolean>(false);
126
120
  const [savingError, setSavingError] = useState<Error | undefined>();
127
121
  const [isSavingAutoSave, setIsSavingAutoSave] = useState<boolean>(false);
128
122
  const [discardDialogOpen, setDiscardDialogOpen] = useState<boolean>(false);
@@ -151,7 +145,6 @@ export function EntityForm<M extends Record<string, unknown>>({
151
145
  const onSubmit = (values: EntityValues<M>, formexController: FormexController<EntityValues<M>>) => {
152
146
 
153
147
  setSavingError(undefined);
154
- setEntityIdError(false);
155
148
 
156
149
  if (status === "existing") {
157
150
  if (!entity?.id) throw Error("Form misconfiguration when saving, no id for existing entity");
@@ -307,26 +300,33 @@ export function EntityForm<M extends Record<string, unknown>>({
307
300
  savingError,
308
301
  status,
309
302
  openEntityMode,
310
- disabled
303
+ disabled,
304
+ isSaving: formex.isSubmitting || isSavingAutoSave
311
305
  };
312
306
 
313
307
  useEffect(() => {
314
308
  onFormContextReady?.(formContext);
315
- }, [formex.version, collection, entityId, path]);
309
+ // `formex.version` only moves on `resetForm`, so these deps used to hand
310
+ // the container one snapshot at mount and never another. That was
311
+ // invisible while Save lived inside the form and read `formex` directly;
312
+ // once the identity bar owned Save, its `dirty` was frozen false and the
313
+ // button never enabled — for any field, not just the one that exposed
314
+ // it. Custom entity views read `formContext.values` through the same
315
+ // channel and were reading a stale copy too.
316
+ //
317
+ // Deps are the state *transitions* a container cares about, not
318
+ // `formex.values`: re-publishing on every keystroke would re-render the
319
+ // whole entity view per character.
320
+ }, [
321
+ formex.version, collection, entityId, path,
322
+ formex.dirty, formex.isSubmitting, formex.submitCount, isSavingAutoSave
323
+ ]);
316
324
 
317
325
  const actionsDisabled = disabled || formex.isSubmitting || (status === "existing" && !formex.dirty) || Boolean(disabledProp);
318
326
 
319
- const titlePropertyKey = getEntityTitlePropertyKeyForEntity(collection, formex.values as Record<string, unknown> | undefined, entityId);
320
- const rawTitle = formex.values && titlePropertyKey ? getValueInPath(formex.values, titlePropertyKey) : undefined;
321
- // A user picker stores an id: resolve it to the person, like a relation.
322
- const titleUser = useResolvedUser(isUserSelectProperty(collection, titlePropertyKey) && typeof rawTitle === "string"
323
- ? rawTitle
324
- : undefined);
325
- const title = titleUser
326
- ? getUserLabel(titleUser)
327
- : (rawTitle !== undefined && rawTitle !== null
328
- ? resolveTitleToString(rawTitle)
329
- : (collection.singularName ?? collection.name));
327
+ // The record's title is resolved by the container that renders the identity
328
+ // bar (see `useEntityDisplayTitle`), not here the form no longer draws a
329
+ // heading of its own.
330
330
 
331
331
  const modified = formex.dirty;
332
332
 
@@ -370,6 +370,145 @@ export function EntityForm<M extends Record<string, unknown>>({
370
370
 
371
371
  const formFieldKeys = getFormFieldKeys(collection);
372
372
 
373
+ const layout = useMemo(() => resolveFormLayout({
374
+ collection,
375
+ fieldKeys: formFieldKeys,
376
+ status
377
+ }), [collection, formFieldKeys.join(","), status]);
378
+
379
+ const formElementRef = useRef<HTMLFormElement>(null);
380
+ // Measured, not a breakpoint: the rail holds fields the resolver moved
381
+ // out of the column, so when it cannot be shown they have to come back.
382
+ const railVisible = useRailVisible(formElementRef);
383
+ const showRail = !Builder && layout.hasRail && railVisible;
384
+
385
+ // Collapsed sections are per-section-key so a renamed title keeps its state,
386
+ // and initial state comes from the config rather than being forced open.
387
+ const [collapsedSections, setCollapsedSections] = useState<Set<string>>(
388
+ () => new Set(layout.sections.filter(s => s.collapsed).map(s => s.key))
389
+ );
390
+ const toggleSection = useCallback((key: string) => {
391
+ setCollapsedSections(prev => {
392
+ const next = new Set(prev);
393
+ if (next.has(key)) next.delete(key); else next.add(key);
394
+ return next;
395
+ });
396
+ }, []);
397
+
398
+ const errorKeys = useMemo(
399
+ () => new Set(formex.submitCount > 0 ? Object.keys(formex.errors) : []),
400
+ [formex.errors, formex.submitCount]
401
+ );
402
+
403
+ // Autofocus lands on the first field the user can actually type into. Tracked
404
+ // across the whole layout rather than per section, so a collapsed leading
405
+ // group does not steal it.
406
+ const autoFocusKey = useMemo(() => {
407
+ if (status !== "new" && status !== "copy") return undefined;
408
+ const ordered = [...layout.sections.flatMap(s => s.fields), ...layout.sidebar];
409
+ return ordered.find(field => {
410
+ const property = collection.properties?.[field.key];
411
+ return property ? !isFieldDisabled(property) : false;
412
+ })?.key;
413
+ }, [layout, collection, status, disabledProp]);
414
+
415
+ function isFieldDisabled(property: NonNullable<typeof collection.properties[string]>): boolean {
416
+ const isNew = status === "new" || status === "copy";
417
+ const isStringOrNumber = property.type === "string" || property.type === "number";
418
+ const isIdAndAuto = isStringOrNumber && "isId" in property && typeof property.isId === "string" && property.isId !== "manual";
419
+ return Boolean(
420
+ disabledProp
421
+ || (!autoSave && formex.isSubmitting)
422
+ || isReadOnly(property)
423
+ || Boolean(property.admin?.disabled)
424
+ || (!isNew && "isId" in property && Boolean(property.isId))
425
+ || (isNew && isIdAndAuto)
426
+ );
427
+ }
428
+
429
+ const renderField = (field: ResolvedFormField): React.ReactNode => {
430
+
431
+ if (field.additional) {
432
+ const additionalField = collection.additionalFields?.find(f => f.key === field.key);
433
+ if (!additionalField || !entity) return null;
434
+
435
+ const AdditionalFieldBuilder = additionalField.Builder;
436
+ if (!AdditionalFieldBuilder && !additionalField.value) {
437
+ throw new Error("When using additional fields you need to provide a Builder or a value");
438
+ }
439
+ const additionalFieldContext = formContext as unknown as AdditionalFieldDelegateProps["context"];
440
+ const child = AdditionalFieldBuilder
441
+ ? <AdditionalFieldBuilder entity={entity} context={additionalFieldContext}/>
442
+ : <div className={"w-full"}>
443
+ {additionalField.value?.({
444
+ entity,
445
+ context: additionalFieldContext
446
+ })?.toString()}
447
+ </div>;
448
+
449
+ return (
450
+ <div key={`additional_${field.key}`}
451
+ 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>
459
+ {child}
460
+ </ErrorBoundary>
461
+ </div>
462
+ </div>
463
+ );
464
+ }
465
+
466
+ const property = collection.properties?.[field.key];
467
+ if (!property) {
468
+ console.warn(`Property ${field.key} not found in collection ${collection.name} in properties or additional fields. Skipping.`);
469
+ return null;
470
+ }
471
+
472
+ const underlyingValueHasChanged: boolean =
473
+ !!underlyingChanges &&
474
+ Object.keys(underlyingChanges).includes(field.key) &&
475
+ formex.touched[field.key];
476
+
477
+ const disabled = isFieldDisabled(property);
478
+ // The panel-style editors carry their own header; everything else is
479
+ // labelled by FieldBlock, which is what puts every label in one place.
480
+ const selfLabelling = isSelfLabellingProperty(property);
481
+
482
+ const cmsFormFieldProps: PropertyFieldBindingProps<M> = {
483
+ propertyKey: field.key,
484
+ disabled,
485
+ property,
486
+ // FieldBlock owns the description now, so the binding must not
487
+ // repeat it — it still renders validation errors, which belong
488
+ // against the control rather than under the label.
489
+ includeDescription: false,
490
+ hideLabel: !selfLabelling,
491
+ underlyingValueHasChanged: underlyingValueHasChanged && !autoSave,
492
+ context: formContext,
493
+ partOfArray: false,
494
+ minimalistView: false,
495
+ // One height for every control in the form. Left to their own
496
+ // defaults, a text field came out 48px, a select 44 and a switch 64.
497
+ size: "large",
498
+ autoFocus: autoFocusKey === field.key
499
+ };
500
+
501
+ return (
502
+ <div key={`field_${field.key}`} className={spanClass(field.span)}>
503
+ <FieldBlock propertyKey={field.key}
504
+ property={property}
505
+ showLabel={!selfLabelling}>
506
+ <PropertyFieldBinding {...cmsFormFieldProps}/>
507
+ </FieldBlock>
508
+ </div>
509
+ );
510
+ };
511
+
373
512
  const formFields = () => {
374
513
 
375
514
  if (Builder) {
@@ -380,89 +519,30 @@ export function EntityForm<M extends Record<string, unknown>>({
380
519
  formContext={formContext}
381
520
  />;
382
521
  }
383
- const isNewEntity = status === "new" || status === "copy";
384
- let firstFocusableIndex = -1;
385
-
386
- return (
387
- <FormLayout>
388
- {formFieldKeys.map((key) => {
389
- const property = collection.properties?.[key];
390
- if (property) {
391
-
392
- const underlyingValueHasChanged: boolean =
393
- !!underlyingChanges &&
394
- Object.keys(underlyingChanges).includes(key) &&
395
- formex.touched[key];
396
- const isNew = status === "new" || status === "copy";
397
- const isStringOrNumber = property.type === "string" || property.type === "number";
398
- const isIdAndAuto = isStringOrNumber && "isId" in property && typeof property.isId === "string" && property.isId !== "manual";
399
- const disabled = disabledProp || (!autoSave && formex.isSubmitting) || isReadOnly(property) || Boolean(property.admin?.disabled) || (!isNew && "isId" in property && Boolean(property.isId)) || (isNew && isIdAndAuto);
400
- const hidden = isHidden(property);
401
- if (hidden) return null;
402
- const widthPercentage = property.admin?.widthPercentage ?? 100;
403
-
404
- const shouldAutoFocus = isNewEntity && !disabled && firstFocusableIndex === -1;
405
- if (shouldAutoFocus) firstFocusableIndex = 0;
406
-
407
- const cmsFormFieldProps: PropertyFieldBindingProps<M> = {
408
- propertyKey: key,
409
- disabled,
410
- property,
411
- includeDescription: Boolean(property.description),
412
- underlyingValueHasChanged: underlyingValueHasChanged && !autoSave,
413
- context: formContext,
414
- partOfArray: false,
415
- minimalistView: false,
416
- autoFocus: shouldAutoFocus
417
- };
418
-
419
- return (
420
- <FormEntry propertyKey={key}
421
- widthPercentage={widthPercentage}
422
- key={`field_${key}`}>
423
- <PropertyFieldBinding {...cmsFormFieldProps}/>
424
- </FormEntry>
425
- );
426
- }
427
522
 
428
- const additionalField = collection.additionalFields?.find(f => f.key === key);
429
- if (additionalField && entity) {
430
- const AdditionalFieldBuilder = additionalField.Builder;
431
- if (!AdditionalFieldBuilder && !additionalField.value) {
432
- throw new Error("When using additional fields you need to provide a Builder or a value");
433
- }
434
- const additionalFieldContext = formContext as unknown as AdditionalFieldDelegateProps['context'];
435
- const child = AdditionalFieldBuilder
436
- ? <AdditionalFieldBuilder entity={entity} context={additionalFieldContext}/>
437
- : <div className={"w-full"}>
438
- {additionalField.value?.({
439
- entity,
440
- context: additionalFieldContext
441
- })?.toString()}
442
- </div>;
443
-
444
- return (
445
- <div key={`additional_${key}`} className={"w-full"}>
446
- <LabelWithIconAndTooltip
447
- propertyKey={key}
448
- icon={<AlignLeftIcon size={iconSize.small}/>}
449
- title={additionalField.name}
450
- className={"text-text-secondary dark:text-text-secondary-dark ml-3.5"}/>
451
- <div
452
- className={cls(paperMixin, "w-full min-h-14 p-4 md:p-6 overflow-x-scroll no-scrollbar")}>
453
- <ErrorBoundary>
454
- {child}
455
- </ErrorBoundary>
456
- </div>
457
- </div>
458
- );
459
- }
460
-
461
- console.warn(`Property ${key} not found in collection ${collection.name} in properties or additional fields. Skipping.`);
462
- return null;
463
- }).filter(Boolean)}
464
- </FormLayout>
465
- );
523
+ const sections = showRail || !layout.sidebar.length
524
+ ? layout.sections
525
+ : [
526
+ ...layout.sections,
527
+ // Trailing, not leading. Beside the column these read as
528
+ // peripheral; stacked above it they pushed the record's own name
529
+ // below its status, which is the wrong thing to meet first.
530
+ // Settings come after the content they apply to.
531
+ {
532
+ key: "__rail",
533
+ title: "Settings",
534
+ collapsible: false,
535
+ collapsed: false,
536
+ fields: layout.sidebar
537
+ }
538
+ ];
539
+
540
+ return <FormSections
541
+ sections={sections}
542
+ collapsed={collapsedSections}
543
+ onToggle={toggleSection}
544
+ errorKeys={errorKeys}
545
+ renderField={renderField}/>;
466
546
  };
467
547
 
468
548
  const formRef = useRef<HTMLDivElement>(null);
@@ -473,25 +553,15 @@ export function EntityForm<M extends Record<string, unknown>>({
473
553
  <>
474
554
  {beforeFields}
475
555
 
476
- {!Builder && <div className={"w-full flex flex-col items-start my-4 lg:my-6"}>
477
- <Typography
478
- className={"my-4 grow line-clamp-1 " + (collection.hideIdFromForm ? "mb-6" : "")}
479
- variant={"h4"}>
480
- {title ?? collection.singularName ?? collection.name}
481
- </Typography>
482
-
483
- {!entity?.values && initialStatus === "existing" &&
484
- <Alert color={"warning"} size={"small"} outerClassName={"w-full mb-4 text-xs"}>
485
- This entity does not exist yet
486
- </Alert>}
487
-
488
- {showEntityPath && <Alert color={"base"} outerClassName={"w-full"} size={"small"}>
489
- <code
490
- className={"text-xs select-all text-text-secondary dark:text-text-secondary-dark"}>
491
- {entity?.path ?? path}/{entityId}
492
- </code>
556
+ {/* The title, the `path/id` chip and the 72px gap under them used to
557
+ live here — 219px above the first field, carrying nothing, and
558
+ scrolling the record's identity away on the first wheel click.
559
+ It is now in the persistent bar, where it stays put. */}
560
+
561
+ {!entity?.values && initialStatus === "existing" &&
562
+ <Alert color={"warning"} size={"small"} outerClassName={"w-full mb-4 text-xs"}>
563
+ This entity does not exist yet
493
564
  </Alert>}
494
- </div>}
495
565
 
496
566
  {children}
497
567
 
@@ -504,7 +574,7 @@ export function EntityForm<M extends Record<string, unknown>>({
504
574
  </Alert>}
505
575
 
506
576
  {formContext && <>
507
- <div className="mt-12 flex flex-col gap-8" ref={formRef}>
577
+ <div ref={formRef}>
508
578
  {formFields()}
509
579
  <ErrorFocus containerRef={formRef}/>
510
580
  </div>
@@ -512,8 +582,6 @@ export function EntityForm<M extends Record<string, unknown>>({
512
582
 
513
583
  {afterFields}
514
584
 
515
- {forceActionsAtTheBottom && <div className="h-16"/>}
516
-
517
585
  <Dialog open={discardDialogOpen} onOpenChange={setDiscardDialogOpen} maxWidth={"sm"}>
518
586
  <DialogTitle>{status === "existing" ? "Discard changes?" : "Clear form?"}</DialogTitle>
519
587
  <DialogContent>
@@ -553,7 +621,6 @@ export function EntityForm<M extends Record<string, unknown>>({
553
621
  collection={collection}
554
622
  path={path}
555
623
  entity={entity}
556
- layout={forceActionsAtTheBottom ? "bottom" : "responsive"}
557
624
  savingError={savingError}
558
625
  formex={formex as FormexController<Record<string, unknown>>}
559
626
  disabled={actionsDisabled}
@@ -568,6 +635,7 @@ export function EntityForm<M extends Record<string, unknown>>({
568
635
  return (
569
636
  <Formex value={formex}>
570
637
  <form
638
+ ref={formElementRef}
571
639
  onSubmit={formex.handleSubmit}
572
640
  onReset={(e) => {
573
641
  e.preventDefault();
@@ -578,55 +646,56 @@ export function EntityForm<M extends Record<string, unknown>>({
578
646
  }
579
647
  }}
580
648
  noValidate
581
- className={cls("@container flex-1 flex flex-row w-full overflow-y-auto justify-center", className)}>
582
- <div
583
- id={`form_${path}`}
584
- className={cls("relative flex flex-row max-w-4xl lg:max-w-3xl xl:max-w-4xl 2xl:max-w-6xl w-full h-fit")}>
585
-
586
- <div className={cls(
587
- "flex flex-col w-full",
588
- openEntityMode === "dialog"
589
- ? "pt-4 pb-12 px-6 sm:px-8"
590
- : "pt-12 pb-16 px-4 sm:px-8 md:px-10"
591
- )}>
649
+ className={cls("@container/form flex-1 flex flex-col w-full min-h-0", className)}>
650
+
651
+ {/* The actions bar is a sibling of this row rather than absolutely
652
+ positioned over it, so the scroll area ends where the bar
653
+ begins. As an overlay it hid the last field, and the bottom
654
+ padding that compensated was a guess at its height. */}
655
+ <div className={"flex-1 min-h-0 flex flex-row w-full"}>
656
+
657
+ {/* Main column. Its own container scope, so a field's span
658
+ answers to the column it sits in rather than to the window —
659
+ the same form renders at four very different widths. */}
660
+ {/* `items-start`: the column is a flex item, so without it the cross-axis
661
+ default stretches it to the scroller's height. Its content then
662
+ overflows its own box and the bottom padding sits at the bottom of
663
+ the *viewport* instead of after the last field — which is why the
664
+ form appeared to have no padding however large `pb-*` grew. */}
665
+ <div className={"flex-1 min-w-0 overflow-y-auto flex justify-center items-start"}>
592
666
  <div
593
- className={"flex flex-row gap-4 justify-end h-0 overflow-visible sticky top-4 z-10"}>
667
+ id={`form_${path}`}
668
+ className={cls(
669
+ "@container/col w-full max-w-3xl 2xl:max-w-4xl flex flex-col",
670
+ openEntityMode === "dialog"
671
+ ? "pt-5 pb-8 px-6 sm:px-8"
672
+ : "pt-6 pb-12 px-5 sm:px-8"
673
+ )}>
594
674
 
595
675
  {manualApplyLocalChanges && hasLocalChanges && localChangesCacheKey &&
596
- <LocalChangesMenu<M>
597
- cacheKey={localChangesCacheKey}
598
- properties={collection.properties}
599
- cachedData={localChangesData as Partial<M>}
600
- formex={formex}
601
- onClearLocalChanges={() => {
602
- setLocalChangesCleared(true);
603
- onClearLocalChanges?.();
604
- }}
605
- />}
606
-
607
- {isSavingAutoSave
608
- ? <Tooltip title={"Saving…"}>
609
- <Chip size={"small"} className={"py-1"} colorScheme={"blueDarker"}>
610
- <LoaderIcon size={iconSize.smallest} className={"animate-spin"}/>
611
- </Chip>
612
- </Tooltip>
613
- : formex.dirty
614
- ? <Tooltip title={"Form has been modified"}>
615
- <Chip size={"small"} className={"py-1"} colorScheme={"orangeDarker"}>
616
- <PencilIcon size={iconSize.smallest}/>
617
- </Chip>
618
- </Tooltip>
619
- : <Tooltip title={"Form is in sync"}>
620
- <Chip size={"small"} className={"py-1"}>
621
- <CheckIcon size={iconSize.smallest}/>
622
- </Chip>
623
- </Tooltip>}
624
- </div>
625
-
626
- {formView}
676
+ <div className={"flex justify-end mb-3"}>
677
+ <LocalChangesMenu<M>
678
+ cacheKey={localChangesCacheKey}
679
+ properties={collection.properties}
680
+ cachedData={localChangesData as Partial<M>}
681
+ formex={formex}
682
+ onClearLocalChanges={() => {
683
+ setLocalChangesCleared(true);
684
+ onClearLocalChanges?.();
685
+ }}
686
+ />
687
+ </div>}
688
+
689
+ {formView}
627
690
 
691
+ </div>
628
692
  </div>
629
693
 
694
+ {showRail && <FormRail
695
+ fields={layout.sidebar}
696
+ showRecordMeta={layout.showRecordMeta && status === "existing"}
697
+ entity={entity as Entity<Record<string, unknown>> | undefined}
698
+ renderField={renderField}/>}
630
699
  </div>
631
700
 
632
701
  {dialogActions}
@@ -637,6 +706,25 @@ export function EntityForm<M extends Record<string, unknown>>({
637
706
  );
638
707
  }
639
708
 
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
+
640
728
  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>) {
641
729
  useEffect(() => {
642
730
  if (!autoSave) return;