@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
@@ -9,8 +9,6 @@ import { getIcon } from "@rebasepro/app";
9
9
  import {
10
10
  AlertCircleIcon,
11
11
  Button,
12
- cls,
13
- defaultBorderMixin,
14
12
  DialogActions,
15
13
  IconButton,
16
14
  LoadingButton,
@@ -19,11 +17,20 @@ import {
19
17
  } from "@rebasepro/ui";
20
18
  import { FormexController } from "@rebasepro/forms";
21
19
 
20
+ /**
21
+ * The form's own action bar.
22
+ *
23
+ * There used to be two variants: this one, and a `w-80 2xl:w-96` sticky rail
24
+ * holding the same two buttons above several hundred pixels of nothing — a
25
+ * fifth of a 1440px viewport reserved for a Save button. The rail is gone. When
26
+ * the container supplies its own actions (the entity view's identity bar does)
27
+ * it passes `showDefaultActions={false}`, and this renders only what the bar
28
+ * cannot host: the saving error, plugin actions, and collection form actions.
29
+ */
22
30
  export function EntityFormActions({
23
31
  path,
24
32
  collection,
25
33
  entity,
26
- layout,
27
34
  savingError,
28
35
  formex,
29
36
  disabled,
@@ -31,10 +38,11 @@ export function EntityFormActions({
31
38
  pluginActions,
32
39
  openEntityMode,
33
40
  navigateBack,
34
- formContext
41
+ formContext,
42
+ showDefaultActions = true
35
43
  }: EntityFormActionsProps) {
36
44
 
37
- const bottomActionsProps = {
45
+ return buildBottomActions({
38
46
  path,
39
47
  savingError,
40
48
  entity,
@@ -46,35 +54,8 @@ export function EntityFormActions({
46
54
  navigateBack,
47
55
  formContext,
48
56
  formex,
49
- className: layout === "responsive" ? "@6xl:hidden" : undefined
50
- };
51
-
52
- const sideActionsProps = {
53
- path,
54
- savingError,
55
- entity,
56
- collection,
57
- disabled,
58
- status,
59
- pluginActions,
60
- openEntityMode,
61
- navigateBack,
62
- formContext,
63
- formex,
64
- className: layout === "responsive" ? "hidden @6xl:flex" : undefined
65
- };
66
-
67
- const bottomActions = buildBottomActions(bottomActionsProps);
68
- const sideActions = buildSideActions(sideActionsProps);
69
-
70
- if (layout === "responsive") {
71
- return <>
72
- {bottomActions}
73
- {sideActions}
74
- </>;
75
- }
76
-
77
- return layout === "bottom" ? bottomActions : sideActions;
57
+ showDefaultActions
58
+ });
78
59
  }
79
60
 
80
61
  type ActionsViewProps<M extends Record<string, unknown>> = {
@@ -90,6 +71,7 @@ type ActionsViewProps<M extends Record<string, unknown>> = {
90
71
  navigateBack: () => void;
91
72
  formContext: FormContext,
92
73
  formex: FormexController<Record<string, unknown>>;
74
+ showDefaultActions: boolean;
93
75
  className?: string;
94
76
  };
95
77
 
@@ -106,19 +88,30 @@ function buildBottomActions<M extends Record<string, unknown>>({
106
88
  navigateBack,
107
89
  formContext,
108
90
  formex,
91
+ showDefaultActions,
109
92
  className
110
93
  }: ActionsViewProps<M>) {
111
94
 
112
95
  const hasErrors = Object.keys(formex.errors).length > 0 && formex.submitCount > 0;
96
+ const entityActions = entity ? (formActions ?? []) : [];
97
+ const hasPluginActions = Boolean(pluginActions && (pluginActions as React.ReactNode[]).length > 0);
98
+
99
+ // Nothing to show means no footer at all, rather than an empty bar pinned
100
+ // over the last field. This is also what made `showDefaultActions` start
101
+ // meaning something: it was accepted as a prop and then never read, so
102
+ // passing `false` silently kept the buttons.
103
+ if (!showDefaultActions && !savingError && !entityActions.length && !hasPluginActions) {
104
+ return null;
105
+ }
113
106
 
114
- return <DialogActions position={"absolute"} className={className}>
107
+ return <DialogActions position={"sticky"} className={className}>
115
108
  {savingError &&
116
109
  <div className="text-right">
117
110
  <Typography color={"error"}>{savingError.message}</Typography>
118
111
  </div>
119
112
  }
120
- {entity && (formActions ?? []).length > 0 && <div className="grow flex overflow-auto no-scrollbar">
121
- {(formActions ?? []).map(action => (
113
+ {entityActions.length > 0 && <div className="grow flex overflow-auto no-scrollbar">
114
+ {entityActions.map(action => (
122
115
  <IconButton
123
116
  key={action.name}
124
117
  color="primary"
@@ -142,68 +135,26 @@ function buildBottomActions<M extends Record<string, unknown>>({
142
135
  ))}
143
136
  </div>}
144
137
  {pluginActions}
145
- <Button variant="text" disabled={disabled || formex.isSubmitting}
146
- color={"primary"}
147
- type="reset">
148
- {status === "existing" ? "Discard" : "Clear"}
149
- </Button>
150
- <Tooltip title={hasErrors ? "Fix highlighted errors before saving" : undefined}>
151
- <LoadingButton variant={"filled"}
152
- color="primary"
153
- type="submit"
154
- loading={formex.isSubmitting}
155
- disabled={disabled || formex.isSubmitting}
156
- startIcon={hasErrors ? <AlertCircleIcon/> : undefined}>
157
- {status === "existing" && "Save"}
158
- {status === "copy" && "Create copy"}
159
- {status === "new" && "Create"}
160
- </LoadingButton>
161
- </Tooltip>
162
138
 
163
- </DialogActions>;
164
- }
165
-
166
- function buildSideActions<M extends Record<string, unknown>>({
167
- savingError,
168
- entity,
169
- formActions,
170
- path,
171
- openEntityMode,
172
- collection,
173
- disabled,
174
- status,
175
- pluginActions,
176
- formex,
177
- className
178
- }: ActionsViewProps<M>) {
179
-
180
- const hasErrors = Object.keys(formex.errors).length > 0 && formex.submitCount > 0;
181
-
182
- return <div
183
- className={cls("overflow-auto h-full flex flex-col gap-2 w-80 2xl:w-96 px-4 py-16 sticky top-0 border-l", defaultBorderMixin, className)}>
184
- <Tooltip title={hasErrors ? "Fix highlighted errors before saving" : undefined}>
185
- <LoadingButton fullWidth={true}
186
- variant="filled"
187
- color="primary"
188
- type="submit"
189
- loading={formex.isSubmitting}
190
- startIcon={hasErrors ? <AlertCircleIcon/> : undefined}
191
- disabled={disabled || formex.isSubmitting}>
192
- {status === "existing" && "Save"}
193
- {status === "copy" && "Create copy"}
194
- {status === "new" && "Create"}
195
- </LoadingButton>
196
- </Tooltip>
197
- <Button fullWidth={true} variant="text" disabled={disabled || formex.isSubmitting} type="reset">
198
- {status === "existing" ? "Discard" : "Clear"}
199
- </Button>
200
-
201
- {pluginActions}
139
+ {showDefaultActions && <>
140
+ <Button variant="text" disabled={disabled || formex.isSubmitting}
141
+ color={"primary"}
142
+ type="reset">
143
+ {status === "existing" ? "Discard" : "Clear"}
144
+ </Button>
145
+ <Tooltip title={hasErrors ? "Fix highlighted errors before saving" : undefined}>
146
+ <LoadingButton variant={"filled"}
147
+ color="primary"
148
+ type="submit"
149
+ loading={formex.isSubmitting}
150
+ disabled={disabled || formex.isSubmitting}
151
+ startIcon={hasErrors ? <AlertCircleIcon/> : undefined}>
152
+ {status === "existing" && "Save"}
153
+ {status === "copy" && "Create copy"}
154
+ {status === "new" && "Create"}
155
+ </LoadingButton>
156
+ </Tooltip>
157
+ </>}
202
158
 
203
- {savingError &&
204
- <div className="text-right">
205
- <Typography color={"error"}>{savingError.message}</Typography>
206
- </div>
207
- }
208
- </div>;
159
+ </DialogActions>;
209
160
  }
@@ -287,7 +287,9 @@ export function EntityFormBinding<M extends Record<string, unknown>>({
287
287
  disabled: false
288
288
  }), [entityId, parentCollectionSlugs, parentEntityIds, path, status, collection, context, openEntityMode]);
289
289
 
290
- const pluginFormActions = useSlot("form.actions", formActionProps);
290
+ // `form.actions` is rendered by the entity view's identity bar, which has
291
+ // the real formContext and does not cost a footer. Rendering it here too
292
+ // would show Autofill twice.
291
293
  const pluginFormBefore = useSlot("form.before", formActionProps);
292
294
  const pluginFormAfter = useSlot("form.after", formActionProps);
293
295
 
@@ -314,7 +316,7 @@ export function EntityFormBinding<M extends Record<string, unknown>>({
314
316
  // Slots
315
317
  beforeFields={pluginFormBefore}
316
318
  afterFields={pluginFormAfter}
317
- pluginActions={pluginFormActions ?? []}
319
+ pluginActions={[]}
318
320
  // Local changes
319
321
  computedInitialValues={computedInitialValues as Partial<M>}
320
322
  hasLocalChanges={hasLocalChanges}
@@ -78,6 +78,7 @@ function PropertyFieldBindingInternal<M extends Record<string, unknown> = Record
78
78
  property,
79
79
  context,
80
80
  includeDescription,
81
+ hideLabel,
81
82
  underlyingValueHasChanged,
82
83
  disabled: disabledProp,
83
84
  partOfArray,
@@ -154,6 +155,7 @@ function PropertyFieldBindingInternal<M extends Record<string, unknown> = Record
154
155
  propertyKey,
155
156
  property: resolvedProperty,
156
157
  includeDescription,
158
+ hideLabel,
157
159
  underlyingValueHasChanged,
158
160
  context,
159
161
  disabled,
@@ -188,6 +190,7 @@ function FieldInternal<CustomProps, M extends Record<string, unknown>>
188
190
  propertyKey,
189
191
  property,
190
192
  includeDescription,
193
+ hideLabel,
191
194
  underlyingValueHasChanged,
192
195
  partOfArray,
193
196
  partOfBlock,
@@ -249,6 +252,7 @@ function FieldInternal<CustomProps, M extends Record<string, unknown>>
249
252
  showError,
250
253
  isSubmitting,
251
254
  includeDescription: includeDescription ?? true,
255
+ hideLabel: hideLabel ?? false,
252
256
  property: property as Property,
253
257
  disabled: disabled ?? false,
254
258
  underlyingValueHasChanged: underlyingValueHasChanged ?? false,
@@ -0,0 +1,119 @@
1
+ import type { Property } from "@rebasepro/types";
2
+ import React from "react";
3
+ import { cls, ErrorBoundary, Typography } from "@rebasepro/ui";
4
+ import { getIconForProperty } from "../../util/property_utils";
5
+ import { PropertyIdCopyTooltip } from "../../components/PropertyIdCopyTooltip";
6
+
7
+ /**
8
+ * Does this property's editor supply its own heading?
9
+ *
10
+ * Arrays, maps, `oneOf` blocks and key-value editors render as a collapsible
11
+ * panel whose header *is* the label — hiding it would leave a panel you cannot
12
+ * identify or, worse, cannot expand. Those keep their own; everything else gets
13
+ * its label from {@link FieldBlock}, which is what makes a text field and a
14
+ * select finally agree on where a label goes.
15
+ *
16
+ * Exported because the layout resolver's span rules and this rule have to stay
17
+ * in step: the self-labelling types are exactly the ones that take a full row.
18
+ */
19
+ export function isSelfLabellingProperty(property: Property | undefined): boolean {
20
+ if (!property) return false;
21
+ switch (property.type) {
22
+ case "map":
23
+ // `spreadChildren` inlines the children into the parent grid, so
24
+ // there is no panel and therefore no header to reuse as the label.
25
+ return !property.admin?.spreadChildren;
26
+ case "array": {
27
+ // Most arrays render as an expandable panel whose header is the
28
+ // label — `repeat`, `block`, `custom_array`, `multi_references`.
29
+ // The exceptions are the ones that resolve to an inline control:
30
+ // an enum array is a multi-select and a storage array is a
31
+ // dropzone, and both take their label from the form.
32
+ //
33
+ // This mirrors `getFieldConfig`'s array branch. Getting it backwards
34
+ // is visible: an array of plain strings is a `repeat` panel, and
35
+ // treating it as inline printed "Available Locales" twice, once
36
+ // from the form and once from the panel's own header.
37
+ if (property.oneOf) return true;
38
+ if (Array.isArray(property.of) || !property.of) return true;
39
+ const of = property.of;
40
+ if (of.type === "string" && (of.enum || of.storage)) return false;
41
+ if (of.type === "number" && of.enum) return false;
42
+ return true;
43
+ }
44
+ default:
45
+ return false;
46
+ }
47
+ }
48
+
49
+ export interface FieldBlockProps {
50
+ propertyKey: string;
51
+ property?: Property;
52
+ /** Rendered by the form rather than by the field, unless self-labelling. */
53
+ showLabel: boolean;
54
+ children: React.ReactNode;
55
+ }
56
+
57
+ /**
58
+ * One field's worth of the form grid: label, control, description.
59
+ *
60
+ * The label carries a small type icon so a number reads differently from a
61
+ * relation at a glance, and the description sits under the control where it
62
+ * annotates what you just filled in.
63
+ */
64
+ export function FieldBlock({
65
+ propertyKey,
66
+ property,
67
+ showLabel,
68
+ children
69
+ }: FieldBlockProps) {
70
+
71
+ const description = property?.description?.trim();
72
+ const required = Boolean(property?.validation?.required);
73
+
74
+ return (
75
+ // Top-aligned: with the description below the control, every label is a
76
+ // single line, so the controls line up on their own. (Bottom-aligning
77
+ // was only needed while the description sat between label and control.)
78
+ <div
79
+ id={`form_field_${propertyKey}`}
80
+ className={"relative flex flex-col min-w-0"}>
81
+
82
+ {showLabel && (
83
+ <PropertyIdCopyTooltip propertyKey={propertyKey}>
84
+ <div className={cls(
85
+ "flex items-center gap-1.5 text-sm font-medium leading-tight mb-1.5",
86
+ "text-text-secondary dark:text-text-secondary-dark"
87
+ )}>
88
+ {/* Small and quiet: enough to tell a number from a
89
+ relation at a glance, not enough to compete with the
90
+ field name. */}
91
+ {property && (
92
+ <span className={"shrink-0 text-text-disabled dark:text-text-disabled-dark"}>
93
+ {getIconForProperty(property, "smallest")}
94
+ </span>
95
+ )}
96
+ <span className={"truncate"}>{property?.name ?? propertyKey}</span>
97
+ {required && <span className={"text-red-500 dark:text-red-500 -ml-1"}>*</span>}
98
+ </div>
99
+ </PropertyIdCopyTooltip>
100
+ )}
101
+
102
+ <div className={"min-w-0"}>
103
+ <ErrorBoundary>
104
+ {children}
105
+ </ErrorBoundary>
106
+ </div>
107
+
108
+ {/* Under the control, where it reads as a note about the thing you
109
+ just filled in rather than a subtitle of the next label. */}
110
+ {showLabel && description && (
111
+ <Typography variant={"caption"}
112
+ color={"disabled"}
113
+ className={"mt-1.5 ml-0.5 leading-snug"}>
114
+ {description}
115
+ </Typography>
116
+ )}
117
+ </div>
118
+ );
119
+ }
@@ -0,0 +1,125 @@
1
+ import type { Entity } from "@rebasepro/types";
2
+ import type { ResolvedFormField } from "@rebasepro/app";
3
+ import React from "react";
4
+ import { CheckIcon, cls, CopyIcon, defaultBorderMixin, IconButton, iconSize, Tooltip, Typography } from "@rebasepro/ui";
5
+
6
+ export interface FormRailProps {
7
+ fields: ResolvedFormField[];
8
+ showRecordMeta: boolean;
9
+ entity?: Entity<Record<string, unknown>>;
10
+ renderField: (field: ResolvedFormField) => React.ReactNode;
11
+ }
12
+
13
+ /**
14
+ * The metadata column beside the form.
15
+ *
16
+ * This replaces a `w-80 2xl:w-96` rail that held a Save button and several
17
+ * hundred pixels of nothing. It renders only when it has something to say, and
18
+ * the actions it used to hold now live in the identity bar, where they stay
19
+ * visible while you scroll.
20
+ */
21
+ export function FormRail({
22
+ fields,
23
+ showRecordMeta,
24
+ entity,
25
+ renderField
26
+ }: FormRailProps) {
27
+
28
+ if (!fields.length && !showRecordMeta) return null;
29
+
30
+ return (
31
+ <aside className={cls(
32
+ // No responsive hiding here. The caller decides whether the rail is
33
+ // shown, by measuring; a CSS `hidden` would leave the fields the
34
+ // resolver moved in here unreachable rather than folding them back.
35
+ "flex flex-col gap-6 shrink-0 w-72 border-l overflow-y-auto",
36
+ "px-5 py-6 bg-surface-50 dark:bg-surface-900",
37
+ defaultBorderMixin
38
+ )}>
39
+ {fields.length > 0 && (
40
+ <div className={"flex flex-col gap-5"}>
41
+ {fields.map(renderField)}
42
+ </div>
43
+ )}
44
+
45
+ {showRecordMeta && entity && <RecordMeta entity={entity}/>}
46
+ </aside>
47
+ );
48
+ }
49
+
50
+ function RecordMeta({ entity }: { entity: Entity<Record<string, unknown>> }) {
51
+
52
+ const createdAt = pickDate(entity.values, ["created_at", "createdAt", "created_on"]);
53
+ const updatedAt = pickDate(entity.values, ["updated_at", "updatedAt", "modified_at"]);
54
+
55
+ return (
56
+ <div className={"flex flex-col gap-2.5"}>
57
+ <Typography variant={"caption"}
58
+ className={"text-xs font-semibold uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark"}>
59
+ Record
60
+ </Typography>
61
+
62
+ <MetaRow label={"ID"} value={String(entity.id)} copyable/>
63
+ {createdAt && <MetaRow label={"Created"} value={createdAt}/>}
64
+ {updatedAt && <MetaRow label={"Updated"} value={updatedAt}/>}
65
+ </div>
66
+ );
67
+ }
68
+
69
+ function MetaRow({
70
+ label,
71
+ value,
72
+ copyable
73
+ }: {
74
+ label: string;
75
+ value: string;
76
+ copyable?: boolean;
77
+ }) {
78
+ const [copied, setCopied] = React.useState(false);
79
+
80
+ const copy = () => {
81
+ navigator.clipboard?.writeText(value).then(() => {
82
+ setCopied(true);
83
+ setTimeout(() => setCopied(false), 1600);
84
+ }, () => undefined);
85
+ };
86
+
87
+ return (
88
+ <div className={"flex items-baseline justify-between gap-3 min-w-0"}>
89
+ <span className={"text-xs text-text-disabled dark:text-text-disabled-dark shrink-0"}>
90
+ {label}
91
+ </span>
92
+ <span className={"flex items-center gap-1 min-w-0"}>
93
+ <span className={"font-mono text-xs text-text-secondary dark:text-text-secondary-dark truncate"}
94
+ title={value}>
95
+ {value}
96
+ </span>
97
+ {copyable && (
98
+ <Tooltip title={copied ? "Copied" : "Copy"}>
99
+ <IconButton size={"smallest"} onClick={copy}>
100
+ {copied ? <CheckIcon size={iconSize.smallest}/> : <CopyIcon size={iconSize.smallest}/>}
101
+ </IconButton>
102
+ </Tooltip>
103
+ )}
104
+ </span>
105
+ </div>
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Audit timestamps are a convention, not a contract — a collection may name them
111
+ * anything or not have them at all — so this reads the values it recognises and
112
+ * stays quiet otherwise rather than rendering an empty row.
113
+ */
114
+ function pickDate(values: Record<string, unknown> | undefined, keys: string[]): string | undefined {
115
+ if (!values) return undefined;
116
+ for (const key of keys) {
117
+ const value = values[key];
118
+ if (value instanceof Date) return value.toLocaleDateString();
119
+ if (typeof value === "string" && value) {
120
+ const parsed = new Date(value);
121
+ if (!Number.isNaN(parsed.getTime())) return parsed.toLocaleDateString();
122
+ }
123
+ }
124
+ return undefined;
125
+ }
@@ -0,0 +1,118 @@
1
+ import type { ResolvedFormField, ResolvedFormSection } from "@rebasepro/app";
2
+ import React from "react";
3
+ import { ChevronDownIcon, cls, defaultBorderMixin, iconSize } from "@rebasepro/ui";
4
+
5
+ export interface FormSectionsProps {
6
+ sections: ResolvedFormSection[];
7
+ /** Section keys the user has collapsed. */
8
+ collapsed: ReadonlySet<string>;
9
+ onToggle: (key: string) => void;
10
+ /**
11
+ * Field keys currently showing a validation error. A section holding one is
12
+ * forced open — an error that hides inside a collapsed group is a form the
13
+ * user cannot submit and cannot debug.
14
+ */
15
+ errorKeys: ReadonlySet<string>;
16
+ renderField: (field: ResolvedFormField) => React.ReactNode;
17
+ }
18
+
19
+ export function FormSections({
20
+ sections,
21
+ collapsed,
22
+ onToggle,
23
+ errorKeys,
24
+ renderField
25
+ }: FormSectionsProps) {
26
+
27
+ return (
28
+ <div className={"flex flex-col gap-8"}>
29
+ {sections.map((section) => {
30
+
31
+ const hasError = section.fields.some(f => errorKeys.has(f.key));
32
+ const isCollapsed = section.collapsible && collapsed.has(section.key) && !hasError;
33
+
34
+ return (
35
+ <section key={section.key} className={"min-w-0"}>
36
+
37
+ {section.title && (
38
+ <Header
39
+ title={section.title}
40
+ collapsible={section.collapsible}
41
+ collapsed={isCollapsed}
42
+ controls={`form_section_${section.key}`}
43
+ onToggle={() => onToggle(section.key)}/>
44
+ )}
45
+
46
+ {!isCollapsed && (
47
+ // A container query, not a media query: the form is
48
+ // rendered at four very different widths (full
49
+ // screen, side panel, split pane, dialog) inside the
50
+ // same viewport, so spans have to answer to the
51
+ // column they are in, not to the window.
52
+ <div id={`form_section_${section.key}`}
53
+ className={cls(
54
+ "grid gap-x-4 gap-y-5 min-w-0",
55
+ "grid-cols-1 @2xl:grid-cols-4"
56
+ )}>
57
+ {section.fields.map(renderField)}
58
+ </div>
59
+ )}
60
+ </section>
61
+ );
62
+ })}
63
+ </div>
64
+ );
65
+ }
66
+
67
+ function Header({
68
+ title,
69
+ collapsible,
70
+ collapsed,
71
+ controls,
72
+ onToggle
73
+ }: {
74
+ title: string;
75
+ collapsible: boolean;
76
+ collapsed: boolean;
77
+ /**
78
+ * Id of the region this header shows and hides. Only referenced while that
79
+ * region exists — a collapsed section unmounts its fields, and pointing
80
+ * `aria-controls` at an id that is not in the document is its own defect.
81
+ */
82
+ controls: string;
83
+ onToggle: () => void;
84
+ }) {
85
+
86
+ const content = (
87
+ <>
88
+ {collapsible && (
89
+ <ChevronDownIcon
90
+ size={iconSize.smallest}
91
+ className={cls(
92
+ "text-text-disabled dark:text-text-disabled-dark transition-transform duration-150",
93
+ collapsed && "-rotate-90"
94
+ )}/>
95
+ )}
96
+ <span className={"text-xs font-semibold uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark whitespace-nowrap"}>
97
+ {title}
98
+ </span>
99
+ <span className={cls("flex-1 border-t", defaultBorderMixin)}/>
100
+ </>
101
+ );
102
+
103
+ const className = "flex items-center gap-2.5 w-full mb-3.5";
104
+
105
+ if (!collapsible) {
106
+ return <div className={className}>{content}</div>;
107
+ }
108
+
109
+ return (
110
+ <button type={"button"}
111
+ onClick={onToggle}
112
+ aria-expanded={!collapsed}
113
+ aria-controls={collapsed ? undefined : controls}
114
+ className={cls(className, "text-left hover:opacity-80 transition-opacity")}>
115
+ {content}
116
+ </button>
117
+ );
118
+ }
@@ -1,5 +1,6 @@
1
1
  export * from "./FieldHelperText";
2
2
  export * from "./LabelWithIcon";
3
3
  export * from "./LabelWithIconAndTooltip";
4
- export * from "./FormEntry";
5
- export * from "./FormLayout";
4
+ export * from "./FieldBlock";
5
+ export * from "./FormRail";
6
+ export * from "./FormSections";
@@ -0,0 +1,46 @@
1
+ import React, { useLayoutEffect, useState } from "react";
2
+
3
+ /**
4
+ * Below this the form is a single column and the rail is not shown.
5
+ *
6
+ * Roughly the 288px rail plus a content column still wide enough to hold a
7
+ * four-up row. Above it the rail is worth having even in the split view; below
8
+ * it — a narrow split pane, the side panel, the dialog, a phone — its fields
9
+ * fold back into the column.
10
+ */
11
+ export const RAIL_MIN_WIDTH = 1000;
12
+
13
+ /**
14
+ * Is there room beside the form for the metadata rail?
15
+ *
16
+ * Measured on the form element rather than the viewport, because the same form
17
+ * renders in a side panel and a dialog inside a wide window — a media query
18
+ * would say "desktop" and hand a 600px panel a rail it cannot fit.
19
+ *
20
+ * This has to be a real measurement rather than a CSS `hidden` class: the
21
+ * layout resolver *moves* fields into the rail, so hiding it with CSS did not
22
+ * fold them back into the column, it made them unreachable. Status, category
23
+ * and any other rail field simply could not be edited in the side panel, the
24
+ * dialog, or on a phone.
25
+ */
26
+ export function useRailVisible(ref: React.RefObject<HTMLElement | null>): boolean {
27
+
28
+ const [visible, setVisible] = useState(false);
29
+
30
+ // Layout effect, not effect: measured before paint, so a wide form does
31
+ // not render single-column for a frame and then snap the rail in.
32
+ useLayoutEffect(() => {
33
+ const element = ref.current;
34
+ if (!element) return;
35
+
36
+ const measure = () => setVisible(element.getBoundingClientRect().width >= RAIL_MIN_WIDTH);
37
+ measure();
38
+
39
+ if (typeof ResizeObserver === "undefined") return;
40
+ const observer = new ResizeObserver(measure);
41
+ observer.observe(element);
42
+ return () => observer.disconnect();
43
+ }, [ref]);
44
+
45
+ return visible;
46
+ }