@rebasepro/admin 0.13.0 → 0.13.1-canary.g18cfeb7

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 (143) hide show
  1. package/dist/{CollectionEditorDialog-CjXkWZ64.js → CollectionEditorDialog-CUFXevVg.js} +3 -3
  2. package/dist/{CollectionEditorDialog-CjXkWZ64.js.map → CollectionEditorDialog-CUFXevVg.js.map} +1 -1
  3. package/dist/{PropertyEditView-W8I3YV89.js → PropertyEditView-DDUC7giL.js} +2 -2
  4. package/dist/{PropertyEditView-W8I3YV89.js.map → PropertyEditView-DDUC7giL.js.map} +1 -1
  5. package/dist/{RouterCollectionsStudioView-BsLTNoqX.js → RouterCollectionsStudioView-vzS848Gf.js} +9 -13
  6. package/dist/RouterCollectionsStudioView-vzS848Gf.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/CollectionViewBinding/CollectionViewBinding.d.ts +16 -0
  11. package/dist/components/CollectionViewBinding/SlotValue.d.ts +63 -0
  12. package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +17 -4
  13. package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +12 -0
  14. package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +55 -5
  15. package/dist/components/DetailViewBinding.d.ts +15 -0
  16. package/dist/components/DrawerNavigationGroup.d.ts +9 -6
  17. package/dist/components/DrawerNavigationItem.d.ts +16 -6
  18. package/dist/components/EditViewBinding.d.ts +30 -1
  19. package/dist/components/EntityDisplayHeader.d.ts +53 -0
  20. package/dist/components/EntityIdentityBar.d.ts +43 -1
  21. package/dist/components/EntityViewBinding.d.ts +60 -6
  22. package/dist/components/RebaseLayout.d.ts +1 -1
  23. package/dist/components/RebaseShell.d.ts +1 -1
  24. package/dist/components/RelationSelector.d.ts +8 -1
  25. package/dist/components/UserSelector.d.ts +8 -1
  26. package/dist/components/app/Scaffold.d.ts +9 -3
  27. package/dist/components/index.d.ts +2 -0
  28. package/dist/{export-J79rMoNj.js → export-5bl2EoSs.js} +2 -2
  29. package/dist/{export-J79rMoNj.js.map → export-5bl2EoSs.js.map} +1 -1
  30. package/dist/form/components/FieldBlock.d.ts +58 -4
  31. package/dist/form/components/FormRail.d.ts +13 -0
  32. package/dist/form/components/FormSections.d.ts +15 -1
  33. package/dist/form/components/useRailVisible.d.ts +5 -4
  34. package/dist/form/form_utils.d.ts +38 -1
  35. package/dist/{history-D7L_JlLO.js → history-BuKKAhoY.js} +4 -4
  36. package/dist/{history-D7L_JlLO.js.map → history-BuKKAhoY.js.map} +1 -1
  37. package/dist/hooks/useEntityDisplay.d.ts +102 -0
  38. package/dist/hooks/useEntityDisplayTitle.d.ts +18 -9
  39. package/dist/{import-BvYl--U4.js → import-BhdWs34H.js} +3 -3
  40. package/dist/{import-BvYl--U4.js.map → import-BhdWs34H.js.map} +1 -1
  41. package/dist/index.d.ts +2 -2
  42. package/dist/index.js +152 -57
  43. package/dist/index.js.map +1 -1
  44. package/dist/preview/compact_text.d.ts +23 -0
  45. package/dist/preview/components/BooleanPreview.d.ts +3 -1
  46. package/dist/preview/property_previews/ArrayOfStringsPreview.d.ts +1 -1
  47. package/dist/preview/property_previews/ArrayOneOfPreview.d.ts +1 -1
  48. package/dist/preview/property_previews/ArrayPropertyPreview.d.ts +1 -1
  49. package/dist/preview/property_previews/MapPropertyPreview.d.ts +1 -1
  50. package/dist/preview/property_previews/StringPropertyPreview.d.ts +1 -1
  51. package/dist/routes/RebaseRoute.d.ts +1 -1
  52. package/dist/types/components/PropertyPreviewProps.d.ts +19 -0
  53. package/dist/util/number_format.d.ts +14 -0
  54. package/dist/util/previews.d.ts +9 -2
  55. package/dist/{util-GiwPgxnL.js → util-DNG7iK0w.js} +3756 -2300
  56. package/dist/util-DNG7iK0w.js.map +1 -0
  57. package/package.json +9 -9
  58. package/src/collection_editor/serializable_types.ts +1 -0
  59. package/src/collection_editor/ui/collection_editor/CollectionsStudioView.tsx +9 -11
  60. package/src/components/AdditionalFieldValue.tsx +68 -0
  61. package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +7 -8
  62. package/src/components/CollectionTableBinding/PropertyTableCell.tsx +2 -2
  63. package/src/components/CollectionTableBinding/column_utils.tsx +15 -6
  64. package/src/components/CollectionTableBinding/internal/popup_field/PopupFormField.tsx +2 -2
  65. package/src/components/CollectionViewBinding/BoardCardBinding.tsx +26 -27
  66. package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +723 -348
  67. package/src/components/CollectionViewBinding/CollectionViewActions.tsx +1 -1
  68. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +38 -8
  69. package/src/components/CollectionViewBinding/EntityCardBinding.tsx +23 -36
  70. package/src/components/CollectionViewBinding/SlotValue.tsx +156 -0
  71. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +23 -7
  72. package/src/components/CollectionViewBinding/SplitListShowButton.tsx +28 -0
  73. package/src/components/CollectionViewBinding/SplitListView.tsx +33 -2
  74. package/src/components/CollectionViewBinding/useBoardDataController.tsx +12 -3
  75. package/src/components/CollectionViewBinding/usePreviewSlots.ts +183 -64
  76. package/src/components/CollectionViewBinding/utils.ts +7 -4
  77. package/src/components/DefaultDrawer.tsx +19 -23
  78. package/src/components/DetailViewBinding.tsx +93 -56
  79. package/src/components/DrawerNavigationGroup.tsx +16 -39
  80. package/src/components/DrawerNavigationItem.tsx +44 -16
  81. package/src/components/EditViewBinding.tsx +215 -42
  82. package/src/components/EntityDisplayHeader.tsx +170 -0
  83. package/src/components/EntityIdentityBar.tsx +163 -23
  84. package/src/components/EntityInspector.tsx +24 -6
  85. package/src/components/EntityPreviewBinding.tsx +58 -18
  86. package/src/components/EntityViewBinding.tsx +342 -41
  87. package/src/components/InlineEntityPreview.tsx +10 -10
  88. package/src/components/RebaseLayout.tsx +2 -2
  89. package/src/components/RebaseShell.tsx +2 -2
  90. package/src/components/ReferenceTable/SelectionTableBinding.tsx +4 -2
  91. package/src/components/RelationSelector.tsx +15 -4
  92. package/src/components/SideDialogs.tsx +15 -3
  93. package/src/components/SidePanelBinding.tsx +122 -66
  94. package/src/components/UserSelector.tsx +15 -4
  95. package/src/components/app/Scaffold.tsx +112 -10
  96. package/src/components/history/LastEditedByIndicator.tsx +7 -12
  97. package/src/components/index.ts +3 -0
  98. package/src/data_import/utils/data.ts +9 -1
  99. package/src/form/EntityForm.tsx +64 -52
  100. package/src/form/EntityFormBinding.tsx +24 -11
  101. package/src/form/PropertyFieldBinding.tsx +2 -2
  102. package/src/form/components/FieldBlock.tsx +80 -10
  103. package/src/form/components/FormRail.tsx +19 -2
  104. package/src/form/components/FormSections.tsx +32 -5
  105. package/src/form/components/StorageUploadProgress.tsx +9 -3
  106. package/src/form/components/useRailVisible.tsx +5 -4
  107. package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +2 -2
  108. package/src/form/field_bindings/BinaryFieldBinding.tsx +10 -5
  109. package/src/form/field_bindings/BlockFieldBinding.tsx +3 -3
  110. package/src/form/field_bindings/GeopointFieldBinding.tsx +23 -16
  111. package/src/form/field_bindings/MapFieldBinding.tsx +2 -2
  112. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +13 -7
  113. package/src/form/field_bindings/MultiSelectFieldBinding.tsx +7 -1
  114. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +7 -1
  115. package/src/form/field_bindings/ReferenceFieldBinding.tsx +1 -1
  116. package/src/form/field_bindings/RelationFieldBinding.tsx +5 -2
  117. package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -3
  118. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +2 -2
  119. package/src/form/field_bindings/UserSelectFieldBinding.tsx +4 -1
  120. package/src/form/field_bindings/VectorFieldBinding.tsx +6 -4
  121. package/src/form/form_utils.ts +78 -0
  122. package/src/hooks/navigation/useBuildCollectionRegistryController.tsx +29 -5
  123. package/src/hooks/useEntityDisplay.tsx +262 -0
  124. package/src/hooks/useEntityDisplayTitle.tsx +27 -38
  125. package/src/index.ts +9 -1
  126. package/src/preview/PropertyPreview.tsx +25 -5
  127. package/src/preview/compact_text.ts +68 -0
  128. package/src/preview/components/BooleanPreview.tsx +5 -2
  129. package/src/preview/components/StorageThumbnail.tsx +5 -2
  130. package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +6 -3
  131. package/src/preview/property_previews/ArrayOfStringsPreview.tsx +26 -1
  132. package/src/preview/property_previews/ArrayOneOfPreview.tsx +10 -1
  133. package/src/preview/property_previews/ArrayPropertyPreview.tsx +10 -1
  134. package/src/preview/property_previews/MapPropertyPreview.tsx +52 -1
  135. package/src/preview/property_previews/NumberPropertyPreview.tsx +9 -1
  136. package/src/preview/property_previews/StringPropertyPreview.tsx +8 -5
  137. package/src/routes/RebaseRoute.tsx +62 -4
  138. package/src/types/components/PropertyPreviewProps.tsx +21 -0
  139. package/src/util/number_format.ts +46 -0
  140. package/src/util/previews.ts +8 -55
  141. package/src/util/property_utils.tsx +6 -1
  142. package/dist/RouterCollectionsStudioView-BsLTNoqX.js.map +0 -1
  143. package/dist/util-GiwPgxnL.js.map +0 -1
@@ -1,11 +1,68 @@
1
- import type { Properties } from "@rebasepro/types";
1
+ import type { Property } from "@rebasepro/types";
2
2
 
3
- import type { CustomizationController, AdminCollection } from "@rebasepro/admin-types";
4
- import React from "react";
3
+ import type { AdditionalFieldDelegateProps, AdminCollection } from "@rebasepro/admin-types";
4
+ import type { ResolvedFormField, ResolvedFormSection } from "@rebasepro/app";
5
+ import type { FormContext } from "../types/fields";
6
+ import React, { useCallback, useMemo, useRef, useState } from "react";
5
7
  import { Entity } from "@rebasepro/types";
6
- import { ExternalLinkIcon, IconButton, Tooltip } from "@rebasepro/ui";
7
- import { useCustomizationController } from "@rebasepro/app";
8
- import { PropertyCollectionView } from "./PropertyCollectionView";
8
+ import {
9
+ AlignLeftIcon,
10
+ cls,
11
+ defaultBorderMixin,
12
+ ErrorBoundary,
13
+ paperMixin
14
+ } from "@rebasepro/ui";
15
+ import { getFormFieldKeys, resolveFormLayout } from "@rebasepro/app";
16
+ import { getValueInPath } from "@rebasepro/utils";
17
+
18
+ import { FieldBlock, LABEL_ICON_SIZE, spanClass } from "../form/components/FieldBlock";
19
+ import { AdditionalFieldValue } from "./AdditionalFieldValue";
20
+ import { EntityDisplayHeader, HEADER_DISPLAY_ROLES } from "./EntityDisplayHeader";
21
+ import { FormSections } from "../form/components/FormSections";
22
+ import { FormRail, RecordMeta } from "../form/components/FormRail";
23
+ import { useEntityDisplayValues } from "../hooks/useEntityDisplay";
24
+ import { useRailVisible } from "../form/components/useRailVisible";
25
+ import { PropertyPreview } from "../preview";
26
+
27
+ /** Nothing can be in error in a view that cannot be submitted. */
28
+ const NO_ERRORS: ReadonlySet<string> = new Set<string>();
29
+
30
+ /**
31
+ * The fields the read-only view lays out, in render order.
32
+ *
33
+ * The same set the form uses, minus two groups. Both are dropped *before* the
34
+ * layout resolves rather than skipped while rendering, because the resolver has
35
+ * by then given each one a place on the grid — skipping later leaves that place
36
+ * as a hole.
37
+ *
38
+ * - The `additionalFields` when there is no form context to hand them: those
39
+ * entries are arbitrary components, not values, and one rendered against no
40
+ * context is nothing at all.
41
+ * - `headerKeys` — the properties {@link EntityDisplayHeader} has already
42
+ * rendered as the record's picture, subtitle, state and labels. Leaving one in
43
+ * prints its value twice on one screen.
44
+ *
45
+ * The record's **title property is not among them**, deliberately. It names the
46
+ * page in the identity bar, but a heading and a labelled field are different
47
+ * things: `Order #` is a column with a name and a description that someone opens
48
+ * the record to read, and dropping it because its text also appears in the
49
+ * breadcrumb removes a value from the record on the grounds that it is legible
50
+ * somewhere else. See {@link HEADER_DISPLAY_ROLES}.
51
+ */
52
+ export function readOnlyFieldKeys(
53
+ collection: AdminCollection,
54
+ hasFormContext: boolean,
55
+ headerKeys?: ReadonlySet<string>
56
+ ): string[] {
57
+ const keys = getFormFieldKeys(collection);
58
+ const additional = hasFormContext
59
+ ? undefined
60
+ : new Set((collection.additionalFields ?? []).map(field => field.key));
61
+
62
+ return keys.filter(key =>
63
+ !additional?.has(key)
64
+ && !headerKeys?.has(key));
65
+ }
9
66
 
10
67
  /**
11
68
  * @group Components
@@ -15,57 +72,301 @@ export interface EntityViewBindingProps<M extends Record<string, unknown>> {
15
72
  collection: AdminCollection<M>;
16
73
  path: string;
17
74
  className?: string;
75
+ /**
76
+ * Lay the record out as a page: a centred, scrolling column with the
77
+ * metadata rail beside it, exactly as the form does. Off for the inline
78
+ * uses — the delete dialog renders the record inside a box it already owns.
79
+ */
80
+ asPage?: boolean;
81
+ /** Padding preset, matching the form's. Only read when `asPage`. */
82
+ openEntityMode?: "side_panel" | "full_screen" | "split" | "dialog";
83
+ /**
84
+ * Needed only to render `additionalFields`, which are arbitrary components
85
+ * that take a form context. Without one those entries are left out rather
86
+ * than rendered against a fake context.
87
+ */
88
+ formContext?: FormContext<M>;
18
89
  }
19
90
 
20
91
  /**
21
92
  * The read-only rendering of a record's values.
22
93
  *
23
- * This used to prepend a synthetic `Id` row of its own. Between that row, the
24
- * collection's own id property, and the `path/id` chip above it, the same UUID
25
- * appeared three times and in the ~340px split pane each copy wrapped over
26
- * four lines, so most of the first screen was one id repeated. The id is now a
27
- * copyable chip in the identity bar, once.
94
+ * This is the same layout as {@link EntityForm}: the sections, the grid spans,
95
+ * the labels and the rail all come from the one resolver, so the record does
96
+ * not rearrange itself when you press Edit. Only the controls differ each
97
+ * field renders its {@link PropertyPreview} instead of an input.
98
+ *
99
+ * It used to be a flat two-column table of every value in the record: no
100
+ * grouping, no widths, a label column sized for short names and a value column
101
+ * that wrapped emails over two lines while the pane beside it stayed empty. A
102
+ * boolean and a markdown body were given the same room.
103
+ *
104
+ * The synthetic `Id` row it used to prepend is gone with the same reasoning the
105
+ * form applies: between that row, the collection's own id property and the
106
+ * `path/id` chip above them, one UUID appeared three times. The id is a
107
+ * copyable chip in the identity bar, and the rail's record block, once each.
108
+ *
109
+ * The labels are the form's labels, icon and all: a field is the same field
110
+ * whether or not you are editing it. What `mode: "read"` drops is the per-field
111
+ * *description*, which is input help rather than part of the label — and what it
112
+ * adds is room. A control is a bordered box that separates itself from the next
113
+ * one; a value is bare text, so the rows and sections are opened up rather than
114
+ * inheriting spacing sized for inputs.
28
115
  */
29
116
  export function EntityViewBinding<M extends Record<string, unknown>>(
30
117
  {
31
118
  entity,
32
119
  collection,
33
- path,
34
- className
120
+ className,
121
+ asPage = false,
122
+ openEntityMode = "full_screen",
123
+ formContext
35
124
  }: EntityViewBindingProps<M>) {
36
125
 
37
- const customizationController: CustomizationController = useCustomizationController();
38
-
39
- // A single surrogate id is the chip in the identity bar, so listing it again
40
- // as the first row here is the duplication the form already removed.
41
- //
42
- // A composite key is different: `entity.id` is then a synthesised address
43
- // (`a:::b`), and the real key columns are data — on a junction row they say
44
- // which two records this joins. Those stay in the table.
45
- const properties: Properties = React.useMemo(() => {
46
- const entries = Object.entries(collection.properties ?? {});
47
- const idKeys = entries.filter(([, p]) => p && "isId" in p && p.isId);
48
- if (idKeys.length !== 1) return collection.properties;
49
- return Object.fromEntries(entries.filter(([key]) => key !== idKeys[0][0])) as Properties;
50
- }, [collection.properties]);
51
- const externalLink = customizationController?.entityLinkBuilder?.({ entity });
126
+ // The record's name, picture, state and labels, lifted out of the grid and
127
+ // into a header. Resolved here rather than inside it because the fields it
128
+ // renders have to come *out* of the layout, and the layout is resolved here.
129
+ const display = useEntityDisplayValues<M>({
130
+ collection,
131
+ entity
132
+ });
133
+
134
+ const headerKeys = useMemo(() => {
135
+ const keys = new Set<string>();
136
+ for (const role of HEADER_DISPLAY_ROLES) {
137
+ // Set only for a role filled by a property of this collection. A
138
+ // role filled by a resolver has no field on the grid to remove.
139
+ const key = display[role].source?.key;
140
+ if (key) keys.add(key);
141
+ }
142
+ return keys;
143
+ }, [display]);
144
+
145
+ // Only *whether* there is a form context changes the field list, not which
146
+ // one it is — so the memo turns on the boolean rather than on the context
147
+ // object, which is rebuilt on every keystroke of the form above it.
148
+ const hasFormContext = Boolean(formContext);
149
+ const fieldKeys = useMemo(
150
+ () => readOnlyFieldKeys(collection as AdminCollection, hasFormContext, headerKeys),
151
+ [collection, hasFormContext, headerKeys]
152
+ );
153
+
154
+ // `status: "existing"` — a record you are reading exists by definition, and
155
+ // that is what routes a generated id and the audit timestamps out of the
156
+ // column and into the record block.
157
+ const layout = useMemo(() => resolveFormLayout({
158
+ collection,
159
+ fieldKeys,
160
+ status: "existing"
161
+ }), [collection, fieldKeys]);
162
+
163
+ const containerRef = useRef<HTMLDivElement>(null);
164
+ // Measured, not a breakpoint — see `useRailVisible`. The same reasoning as
165
+ // the form's: the resolver *moves* fields into the rail, so when the rail
166
+ // cannot be shown they have to fold back into the column.
167
+ const railVisible = useRailVisible(containerRef);
168
+ const showRail = asPage && layout.hasRail && railVisible;
169
+
170
+ const [collapsedSections, setCollapsedSections] = useState<Set<string>>(
171
+ () => new Set(layout.sections.filter(section => section.collapsed).map(section => section.key))
172
+ );
173
+ const toggleSection = useCallback((key: string) => {
174
+ setCollapsedSections(prev => {
175
+ const next = new Set(prev);
176
+ if (next.has(key)) next.delete(key); else next.add(key);
177
+ return next;
178
+ });
179
+ }, []);
180
+
181
+ const renderField = useCallback((field: ResolvedFormField): React.ReactNode => {
182
+
183
+ if (field.additional) {
184
+ const additionalField = collection.additionalFields?.find(f => f.key === field.key);
185
+ if (!additionalField || !formContext) return null;
186
+
187
+ const AdditionalFieldBuilder = additionalField.Builder;
188
+ const additionalFieldContext = formContext as unknown as AdditionalFieldDelegateProps["context"];
189
+ const child = AdditionalFieldBuilder
190
+ ? <AdditionalFieldBuilder entity={entity} context={additionalFieldContext}/>
191
+ : <div className={"w-full"}>
192
+ <AdditionalFieldValue
193
+ field={additionalField}
194
+ entity={entity}
195
+ context={additionalFieldContext}/>
196
+ </div>;
197
+
198
+ return (
199
+ // Through FieldBlock like every other cell, so a computed
200
+ // column is labelled at the same size, with the same gap to its
201
+ // box, as the property next to it on the grid.
202
+ <div key={`additional_${field.key}`} className={spanClass(field.span)}>
203
+ <FieldBlock propertyKey={field.key}
204
+ showLabel={true}
205
+ mode={"read"}
206
+ label={additionalField.name}
207
+ icon={<AlignLeftIcon size={LABEL_ICON_SIZE}/>}>
208
+ <div className={cls(paperMixin, "w-full min-h-12 p-4 md:p-6 overflow-x-auto no-scrollbar")}>
209
+ {child}
210
+ </div>
211
+ </FieldBlock>
212
+ </div>
213
+ );
214
+ }
215
+
216
+ const property = collection.properties?.[field.key] as Property | undefined;
217
+ if (!property) return null;
218
+
219
+ return (
220
+ <div key={`field_${field.key}`} className={spanClass(field.span)}>
221
+ {/* Always labelled from here, unlike the form. The panel editors
222
+ that carry their own header have no header in preview — a map
223
+ previews as a table of its children and an array as its items,
224
+ so leaving the label to them would leave them unlabelled. */}
225
+ <FieldBlock propertyKey={field.key}
226
+ property={property}
227
+ showLabel={true}
228
+ mode={"read"}>
229
+ {/* One control's height for a single-line value, so a row of
230
+ short fields lines up with the taller ones beside it, and
231
+ anything longer simply grows. Matched to the form's row
232
+ rather than shrunk to the text: it is what keeps a value
233
+ from sitting directly on the label of the row below. */}
234
+ <div className={"min-h-8 flex flex-col justify-center min-w-0 text-text-primary dark:text-text-primary-dark"}>
235
+ <PropertyPreview propertyKey={field.key}
236
+ value={getValueInPath(entity.values, field.key)}
237
+ property={property}
238
+ hideLabel
239
+ size={"medium"}/>
240
+ </div>
241
+ </FieldBlock>
242
+ </div>
243
+ );
244
+ }, [collection, entity, formContext]);
245
+
246
+ /**
247
+ * One field of a `readVariant: "summary"` section: a label/value row rather
248
+ * than a grid cell, with the last one emphasised.
249
+ *
250
+ * The last row is the one a summary exists for — a run of figures is read to
251
+ * arrive at its final line — so it is separated by a rule and set at the
252
+ * weight the grid gave every cell equally.
253
+ */
254
+ const renderSummaryField = useCallback((
255
+ field: ResolvedFormField,
256
+ index: number,
257
+ total: number
258
+ ): React.ReactNode => {
259
+
260
+ const property = collection.properties?.[field.key] as Property | undefined;
261
+ if (!property) return null;
262
+
263
+ const last = index === total - 1;
264
+
265
+ return (
266
+ <div key={`summary_${field.key}`}
267
+ className={cls(
268
+ "flex items-baseline justify-between gap-4 min-w-0 py-2",
269
+ last && total > 1 && cls("mt-1.5 pt-3 border-t", defaultBorderMixin)
270
+ )}>
271
+ <span className={cls(
272
+ "min-w-0 truncate",
273
+ last
274
+ ? "text-sm font-semibold text-text-primary dark:text-text-primary-dark"
275
+ : "text-sm text-text-secondary dark:text-text-secondary-dark"
276
+ )}>
277
+ {property.name ?? field.key}
278
+ </span>
279
+ <span className={cls(
280
+ "shrink-0 text-right text-text-primary dark:text-text-primary-dark",
281
+ last ? "text-base font-semibold" : "text-sm"
282
+ )}>
283
+ <PropertyPreview propertyKey={field.key}
284
+ value={getValueInPath(entity.values, field.key)}
285
+ property={property}
286
+ hideLabel
287
+ size={"medium"}/>
288
+ </span>
289
+ </div>
290
+ );
291
+ }, [collection, entity]);
292
+
293
+ // Same fold-back as the form: with no room for the rail its fields become a
294
+ // trailing group rather than disappearing.
295
+ const sections: ResolvedFormSection[] = showRail || !layout.sidebar.length
296
+ ? layout.sections
297
+ : [
298
+ ...layout.sections,
299
+ {
300
+ key: "__rail",
301
+ title: "Settings",
302
+ collapsible: false,
303
+ collapsed: false,
304
+ fields: layout.sidebar
305
+ }
306
+ ];
307
+
308
+ const content = <>
309
+ {/* The title only when nothing above is already showing it. Every
310
+ full-page use sits under an identity bar that carries it; the
311
+ inline ones — the delete dialog — have no bar. */}
312
+ <EntityDisplayHeader entity={entity}
313
+ collection={collection}
314
+ showTitle={!asPage}
315
+ className={"mb-10"}/>
316
+
317
+ <FormSections sections={sections}
318
+ collapsed={collapsedSections}
319
+ onToggle={toggleSection}
320
+ errorKeys={NO_ERRORS}
321
+ renderField={renderField}
322
+ renderSummaryField={renderSummaryField}
323
+ mode={"read"}/>
324
+
325
+ {/* Folded back with the rail's fields, and for the same reason. The rail
326
+ renders only where it has been measured to fit, so on the split pane,
327
+ the side panel and the dialog this block was dropped outright and the
328
+ record's id was nowhere on screen but the truncated chip in the bar.
329
+ An id you cannot read or copy is the one piece of a record you most
330
+ often opened it for. */}
331
+ {!showRail && layout.showRecordMeta && (
332
+ <div className={cls("mt-10 pt-6 border-t max-w-sm", defaultBorderMixin)}>
333
+ <RecordMeta entity={entity as Entity<Record<string, unknown>>}/>
334
+ </div>
335
+ )}
336
+ </>;
337
+
338
+ if (!asPage) {
339
+ // `@container/col` even here: the spans resolve against the column they
340
+ // sit in, and inside a dialog that column is the dialog.
341
+ return (
342
+ <div ref={containerRef} className={cls("@container/col w-full min-w-0", className)}>
343
+ {content}
344
+ </div>
345
+ );
346
+ }
52
347
 
53
348
  return (
54
- <div className={"w-full " + (className ?? "")}>
55
-
56
- {externalLink && (
57
- <div className={"flex justify-end mb-2"}>
58
- <Tooltip title={"Open in the live site"}>
59
- <a href={externalLink} rel={"noopener noreferrer"} target={"_blank"}>
60
- <IconButton size={"small"}>
61
- <ExternalLinkIcon/>
62
- </IconButton>
63
- </a>
64
- </Tooltip>
349
+ <div ref={containerRef} className={cls("flex-1 flex flex-row w-full min-h-0", className)}>
350
+
351
+ {/* `items-start`: without it the cross-axis default stretches the
352
+ column to the scroller's height and its bottom padding lands at
353
+ the bottom of the viewport instead of after the last field. */}
354
+ <div className={"flex-1 min-w-0 overflow-y-auto flex justify-center items-start"}>
355
+ <div className={cls(
356
+ "@container/col w-full max-w-3xl 2xl:max-w-4xl flex flex-col",
357
+ openEntityMode === "dialog"
358
+ ? "pt-5 pb-8 px-6 sm:px-8"
359
+ : "pt-6 pb-12 px-5 sm:px-8"
360
+ )}>
361
+ {content}
65
362
  </div>
66
- )}
363
+ </div>
67
364
 
68
- <PropertyCollectionView data={entity.values} properties={properties} size={"medium"}/>
365
+ {showRail && <FormRail
366
+ fields={layout.sidebar}
367
+ showRecordMeta={layout.showRecordMeta}
368
+ entity={entity as Entity<Record<string, unknown>>}
369
+ renderField={renderField}/>}
69
370
  </div>
70
371
  );
71
372
  }
@@ -3,10 +3,9 @@ import * as React from "react";
3
3
  import { Entity } from "@rebasepro/types";
4
4
  import { cls, LinkIcon, Skeleton, Tooltip } from "@rebasepro/ui";
5
5
  import { useAnalyticsController } from "@rebasepro/app";
6
- import { getValueInPath } from "@rebasepro/utils";
7
6
  import type { AdminCollection } from "@rebasepro/admin-types";
8
7
 
9
- import { getEntityTitlePropertyKeyForEntity } from "../util/previews";
8
+ import { useEntityTitle } from "../hooks/useEntityDisplay";
10
9
  import { useCollectionRegistryController } from "../hooks/navigation/contexts/CollectionRegistryContext";
11
10
  import { useSidePanel } from "../hooks/useSidePanel";
12
11
 
@@ -57,14 +56,15 @@ export function InlineEntityPreview({
57
56
 
58
57
  const collection = collectionProp ?? collectionRegistryController.getCollection(entity.path);
59
58
 
60
- const titlePropertyKey = collection
61
- ? getEntityTitlePropertyKeyForEntity(collection, entity.values, entity.id)
62
- : undefined;
63
- const titleValue = titlePropertyKey ? getValueInPath(entity.values, titlePropertyKey) : undefined;
64
- const resolvedLabel = label
65
- ?? (titleValue !== undefined && titleValue !== null && String(titleValue).trim().length > 0
66
- ? String(titleValue)
67
- : String(entity.id));
59
+ // The id is the right fallback *here* specifically: this renders a pointer at
60
+ // another record, and a pointer with no label is still followable if it says
61
+ // which record it points at. A page heading makes the opposite choice.
62
+ const { value: title } = useEntityTitle({
63
+ collection: collection as AdminCollection,
64
+ entity,
65
+ enabled: Boolean(collection)
66
+ });
67
+ const resolvedLabel = label ?? title ?? String(entity.id);
68
68
 
69
69
  const interactive = !disabled && (onClick !== undefined || (includeEntityLink && collection !== undefined));
70
70
 
@@ -19,7 +19,7 @@ export interface RebaseLayoutProps {
19
19
  appBar?: React.ReactNode;
20
20
  /** Custom Drawer to override the default. */
21
21
  drawer?: React.ReactNode;
22
- /** Expand the drawer while the pointer is over it. Not a load-time default — that is `defaultDrawerOpen`. */
22
+ /** Expand the drawer while the pointer is over it. On by default; pass `false` to opt out. Not a load-time default — that is `defaultDrawerOpen`. */
23
23
  autoOpenDrawer?: boolean;
24
24
  /** Start with the drawer expanded instead of collapsed to icons. Ignored on small layouts. */
25
25
  defaultDrawerOpen?: boolean;
@@ -52,7 +52,7 @@ export function RebaseLayout(props: RebaseLayoutProps) {
52
52
  title = "Rebase",
53
53
  appBar,
54
54
  drawer,
55
- autoOpenDrawer = false,
55
+ autoOpenDrawer = true,
56
56
  defaultDrawerOpen = false,
57
57
  logo,
58
58
  devViews = []
@@ -14,7 +14,7 @@ export interface RebaseShellProps {
14
14
  /** Top bar. Only rendered if provided — pass `<AppBar/>` for the default one. */
15
15
  appBar?: React.ReactNode;
16
16
  drawer?: React.ReactNode;
17
- /** Expand the drawer while the pointer is over it. Not a load-time default — that is `defaultDrawerOpen`. */
17
+ /** Expand the drawer while the pointer is over it. On by default; pass `false` to opt out. Not a load-time default — that is `defaultDrawerOpen`. */
18
18
  autoOpenDrawer?: boolean;
19
19
  /** Start with the drawer expanded instead of collapsed to icons. Ignored on small layouts. */
20
20
  defaultDrawerOpen?: boolean;
@@ -44,7 +44,7 @@ export function RebaseShell(props: RebaseShellProps) {
44
44
  title = "Rebase",
45
45
  appBar,
46
46
  drawer,
47
- autoOpenDrawer = false,
47
+ autoOpenDrawer = true,
48
48
  defaultDrawerOpen = false,
49
49
  logo,
50
50
  children
@@ -14,7 +14,8 @@ import {
14
14
  useData,
15
15
  useDataTableController,
16
16
  useLargeLayout,
17
- usePermissions
17
+ usePermissions,
18
+ useTranslation
18
19
  } from "@rebasepro/app";
19
20
  import { ErrorView } from "@rebasepro/app";
20
21
  import { Button, DialogActions, PlusIcon, Typography } from "@rebasepro/ui";
@@ -347,6 +348,7 @@ function EntitySelectionDialogActions({
347
348
  const { canCreate } = usePermissions();
348
349
 
349
350
  const largeLayout = useLargeLayout();
351
+ const { t } = useTranslation();
350
352
 
351
353
  const onClick: MouseEventHandler<HTMLButtonElement> | undefined = onNewClick
352
354
  ? (e) => {
@@ -359,7 +361,7 @@ function EntitySelectionDialogActions({
359
361
  ? <Button
360
362
  onClick={onClick}
361
363
  startIcon={<PlusIcon/>}>
362
- Add {collection.singularName ?? collection.name}
364
+ {t("add_specific", { name: collection.singularName ?? collection.name })}
363
365
  </Button>
364
366
  : <Button
365
367
  onClick={onClick}>
@@ -58,7 +58,14 @@ export interface RelationSelectorProps {
58
58
 
59
59
  onValueChange?: (updatedValue: EntityRelation | EntityRelation[] | undefined) => void;
60
60
  placeholder?: React.ReactNode;
61
- size?: "small" | "medium";
61
+ /**
62
+ * `large` is the form's size and is on the shared 28/32/40/48 control
63
+ * scale, so a relation picker lines up with the text field beside it.
64
+ *
65
+ * `small` and `medium` predate that scale (42 and 56) and are kept as they
66
+ * are because the table cells and the filter row are built around them.
67
+ */
68
+ size?: "small" | "medium" | "large";
62
69
  useChips?: boolean;
63
70
  disabled?: boolean;
64
71
  error?: boolean; // kept for backwards compatibility (could be used for styling later)
@@ -488,9 +495,13 @@ relation } as RelationItem;
488
495
  className={cls(
489
496
  {
490
497
  "min-h-[42px] py-1 px-2": size === "small",
491
- "min-h-[56px] py-2 px-4": size === "medium"
498
+ "min-h-[56px] py-2 px-4": size === "medium",
499
+ "min-h-[48px] py-1 px-4": size === "large"
492
500
  },
493
- "w-full select-none rounded-md text-sm relative flex items-center",
501
+ // `rounded-lg` like every other field box —
502
+ // `rounded-md` made this the one control in a
503
+ // row with a different corner.
504
+ "w-full select-none rounded-lg text-sm relative flex items-center",
494
505
  invisible ? fieldBackgroundInvisibleMixin : fieldBackgroundMixin,
495
506
  disabled ? fieldBackgroundDisabledMixin : fieldBackgroundHoverMixin,
496
507
  className
@@ -580,7 +591,7 @@ relation } as RelationItem;
580
591
  </Tooltip>
581
592
  )}
582
593
  <ChevronDownIcon
583
- size={size === "medium" ? iconSize.medium : iconSize.small}
594
+ size={size === "small" ? iconSize.small : iconSize.medium}
584
595
  className={cls("transition", isPopoverOpen ? "rotate-180" : "")}
585
596
  />
586
597
  </div>
@@ -91,7 +91,17 @@ function SideDialogView({
91
91
  const [blocked, setBlocked] = useState(false);
92
92
  const [blockedNavigationMessage, setBlockedNavigationMessage] = useState<React.ReactNode | undefined>();
93
93
 
94
- const [pendingClose, setPendingClose] = useState(false);
94
+ // Held in a ref, read through a getter, because of *when* it is written and
95
+ // read. "Save and close" raises it and submits the form in one click
96
+ // handler, and the callback that acts on it — the form's `onSaved` — was
97
+ // captured before React could re-render with the new value. As state the
98
+ // flag was therefore always read one save late: the first "save and close"
99
+ // saved and left the panel open, and the *next* save closed it.
100
+ // Nothing renders from it, so a ref loses nothing.
101
+ const pendingCloseRef = React.useRef(false);
102
+ const setPendingClose = useCallback((value: boolean) => {
103
+ pendingCloseRef.current = value;
104
+ }, []);
95
105
 
96
106
  const widthRef = React.useRef<string | undefined>(panel?.width);
97
107
  const width = widthRef.current;
@@ -142,9 +152,11 @@ function SideDialogView({
142
152
  setBlockedNavigationMessage,
143
153
  width,
144
154
  close: onCloseRequest,
145
- pendingClose,
155
+ get pendingClose() {
156
+ return pendingCloseRef.current;
157
+ },
146
158
  setPendingClose
147
- }), [blocked, setBlockedNavigationMessage, width, onCloseRequest, pendingClose]);
159
+ }), [blocked, setBlockedNavigationMessage, width, onCloseRequest, setPendingClose]);
148
160
 
149
161
  const additionalProps = panel?.additional as SidePanelBindingProps | undefined;
150
162
  const isDialogMode = additionalProps?.collection?.openEntityMode === "dialog";