@rebasepro/admin 0.13.0 → 0.13.1-canary.g3660bd5

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 (103) hide show
  1. package/dist/{CollectionEditorDialog-CjXkWZ64.js → CollectionEditorDialog-MODRemil.js} +3 -3
  2. package/dist/{CollectionEditorDialog-CjXkWZ64.js.map → CollectionEditorDialog-MODRemil.js.map} +1 -1
  3. package/dist/{PropertyEditView-W8I3YV89.js → PropertyEditView-Bt-VM0zQ.js} +2 -2
  4. package/dist/{PropertyEditView-W8I3YV89.js.map → PropertyEditView-Bt-VM0zQ.js.map} +1 -1
  5. package/dist/{RouterCollectionsStudioView-BsLTNoqX.js → RouterCollectionsStudioView-D54P9o8O.js} +4 -4
  6. package/dist/{RouterCollectionsStudioView-BsLTNoqX.js.map → RouterCollectionsStudioView-D54P9o8O.js.map} +1 -1
  7. package/dist/collection_editor_ui.js +4 -4
  8. package/dist/components/AdditionalFieldValue.d.ts +25 -0
  9. package/dist/components/CollectionViewBinding/CollectionViewBinding.d.ts +16 -0
  10. package/dist/components/CollectionViewBinding/SlotValue.d.ts +37 -0
  11. package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +5 -3
  12. package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +12 -0
  13. package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +29 -4
  14. package/dist/components/DetailViewBinding.d.ts +6 -0
  15. package/dist/components/DrawerNavigationGroup.d.ts +5 -4
  16. package/dist/components/DrawerNavigationItem.d.ts +4 -2
  17. package/dist/components/EditViewBinding.d.ts +7 -1
  18. package/dist/components/EntityDisplayHeader.d.ts +53 -0
  19. package/dist/components/EntityIdentityBar.d.ts +20 -1
  20. package/dist/components/EntityViewBinding.d.ts +60 -6
  21. package/dist/components/RebaseLayout.d.ts +1 -1
  22. package/dist/components/RebaseShell.d.ts +1 -1
  23. package/dist/components/app/Scaffold.d.ts +9 -3
  24. package/dist/components/index.d.ts +2 -0
  25. package/dist/{export-J79rMoNj.js → export-Bl1V7lKO.js} +2 -2
  26. package/dist/{export-J79rMoNj.js.map → export-Bl1V7lKO.js.map} +1 -1
  27. package/dist/form/components/FieldBlock.d.ts +47 -4
  28. package/dist/form/components/FormRail.d.ts +13 -0
  29. package/dist/form/components/FormSections.d.ts +15 -1
  30. package/dist/form/components/useRailVisible.d.ts +5 -4
  31. package/dist/{history-D7L_JlLO.js → history-DN_dzFAF.js} +4 -4
  32. package/dist/{history-D7L_JlLO.js.map → history-DN_dzFAF.js.map} +1 -1
  33. package/dist/hooks/useEntityDisplay.d.ts +102 -0
  34. package/dist/hooks/useEntityDisplayTitle.d.ts +18 -9
  35. package/dist/{import-BvYl--U4.js → import-BJXrHdFv.js} +3 -3
  36. package/dist/{import-BvYl--U4.js.map → import-BJXrHdFv.js.map} +1 -1
  37. package/dist/index.js +149 -51
  38. package/dist/index.js.map +1 -1
  39. package/dist/preview/compact_text.d.ts +23 -0
  40. package/dist/preview/components/BooleanPreview.d.ts +3 -1
  41. package/dist/preview/property_previews/ArrayOfStringsPreview.d.ts +1 -1
  42. package/dist/preview/property_previews/ArrayOneOfPreview.d.ts +1 -1
  43. package/dist/preview/property_previews/ArrayPropertyPreview.d.ts +1 -1
  44. package/dist/preview/property_previews/MapPropertyPreview.d.ts +1 -1
  45. package/dist/preview/property_previews/StringPropertyPreview.d.ts +1 -1
  46. package/dist/routes/RebaseRoute.d.ts +1 -1
  47. package/dist/types/components/PropertyPreviewProps.d.ts +19 -0
  48. package/dist/util/number_format.d.ts +14 -0
  49. package/dist/util/previews.d.ts +9 -2
  50. package/dist/{util-GiwPgxnL.js → util-EXfeGzbH.js} +8142 -7353
  51. package/dist/util-EXfeGzbH.js.map +1 -0
  52. package/package.json +9 -9
  53. package/src/components/AdditionalFieldValue.tsx +68 -0
  54. package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +5 -6
  55. package/src/components/CollectionViewBinding/BoardCardBinding.tsx +9 -27
  56. package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +11 -24
  57. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +26 -6
  58. package/src/components/CollectionViewBinding/EntityCardBinding.tsx +10 -33
  59. package/src/components/CollectionViewBinding/SlotValue.tsx +60 -0
  60. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +7 -5
  61. package/src/components/CollectionViewBinding/SplitListShowButton.tsx +28 -0
  62. package/src/components/CollectionViewBinding/SplitListView.tsx +14 -1
  63. package/src/components/CollectionViewBinding/useBoardDataController.tsx +12 -3
  64. package/src/components/CollectionViewBinding/usePreviewSlots.ts +115 -48
  65. package/src/components/DefaultDrawer.tsx +19 -23
  66. package/src/components/DetailViewBinding.tsx +74 -54
  67. package/src/components/DrawerNavigationGroup.tsx +4 -7
  68. package/src/components/DrawerNavigationItem.tsx +23 -4
  69. package/src/components/EditViewBinding.tsx +54 -33
  70. package/src/components/EntityDisplayHeader.tsx +170 -0
  71. package/src/components/EntityIdentityBar.tsx +49 -5
  72. package/src/components/EntityPreviewBinding.tsx +54 -17
  73. package/src/components/EntityViewBinding.tsx +341 -41
  74. package/src/components/InlineEntityPreview.tsx +10 -10
  75. package/src/components/RebaseLayout.tsx +2 -2
  76. package/src/components/RebaseShell.tsx +2 -2
  77. package/src/components/app/Scaffold.tsx +112 -10
  78. package/src/components/index.ts +3 -0
  79. package/src/form/EntityForm.tsx +28 -33
  80. package/src/form/components/FieldBlock.tsx +65 -8
  81. package/src/form/components/FormRail.tsx +19 -2
  82. package/src/form/components/FormSections.tsx +32 -5
  83. package/src/form/components/StorageUploadProgress.tsx +9 -3
  84. package/src/form/components/useRailVisible.tsx +5 -4
  85. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +7 -1
  86. package/src/hooks/navigation/useBuildCollectionRegistryController.tsx +29 -5
  87. package/src/hooks/useEntityDisplay.tsx +262 -0
  88. package/src/hooks/useEntityDisplayTitle.tsx +27 -38
  89. package/src/preview/PropertyPreview.tsx +18 -2
  90. package/src/preview/compact_text.ts +68 -0
  91. package/src/preview/components/BooleanPreview.tsx +5 -2
  92. package/src/preview/components/StorageThumbnail.tsx +5 -2
  93. package/src/preview/property_previews/ArrayOfStringsPreview.tsx +26 -1
  94. package/src/preview/property_previews/ArrayOneOfPreview.tsx +10 -1
  95. package/src/preview/property_previews/ArrayPropertyPreview.tsx +10 -1
  96. package/src/preview/property_previews/MapPropertyPreview.tsx +52 -1
  97. package/src/preview/property_previews/NumberPropertyPreview.tsx +9 -1
  98. package/src/preview/property_previews/StringPropertyPreview.tsx +8 -5
  99. package/src/routes/RebaseRoute.tsx +62 -4
  100. package/src/types/components/PropertyPreviewProps.tsx +21 -0
  101. package/src/util/number_format.ts +46 -0
  102. package/src/util/previews.ts +8 -55
  103. package/dist/util-GiwPgxnL.js.map +0 -1
@@ -1,11 +1,69 @@
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 { LabelWithIconAndTooltip } from "../form/components/LabelWithIconAndTooltip";
26
+ import { PropertyPreview } from "../preview";
27
+
28
+ /** Nothing can be in error in a view that cannot be submitted. */
29
+ const NO_ERRORS: ReadonlySet<string> = new Set<string>();
30
+
31
+ /**
32
+ * The fields the read-only view lays out, in render order.
33
+ *
34
+ * The same set the form uses, minus two groups. Both are dropped *before* the
35
+ * layout resolves rather than skipped while rendering, because the resolver has
36
+ * by then given each one a place on the grid — skipping later leaves that place
37
+ * as a hole.
38
+ *
39
+ * - The `additionalFields` when there is no form context to hand them: those
40
+ * entries are arbitrary components, not values, and one rendered against no
41
+ * context is nothing at all.
42
+ * - `headerKeys` — the properties {@link EntityDisplayHeader} has already
43
+ * rendered as the record's picture, subtitle, state and labels. Leaving one in
44
+ * prints its value twice on one screen.
45
+ *
46
+ * The record's **title property is not among them**, deliberately. It names the
47
+ * page in the identity bar, but a heading and a labelled field are different
48
+ * things: `Order #` is a column with a name and a description that someone opens
49
+ * the record to read, and dropping it because its text also appears in the
50
+ * breadcrumb removes a value from the record on the grounds that it is legible
51
+ * somewhere else. See {@link HEADER_DISPLAY_ROLES}.
52
+ */
53
+ export function readOnlyFieldKeys(
54
+ collection: AdminCollection,
55
+ hasFormContext: boolean,
56
+ headerKeys?: ReadonlySet<string>
57
+ ): string[] {
58
+ const keys = getFormFieldKeys(collection);
59
+ const additional = hasFormContext
60
+ ? undefined
61
+ : new Set((collection.additionalFields ?? []).map(field => field.key));
62
+
63
+ return keys.filter(key =>
64
+ !additional?.has(key)
65
+ && !headerKeys?.has(key));
66
+ }
9
67
 
10
68
  /**
11
69
  * @group Components
@@ -15,57 +73,299 @@ export interface EntityViewBindingProps<M extends Record<string, unknown>> {
15
73
  collection: AdminCollection<M>;
16
74
  path: string;
17
75
  className?: string;
76
+ /**
77
+ * Lay the record out as a page: a centred, scrolling column with the
78
+ * metadata rail beside it, exactly as the form does. Off for the inline
79
+ * uses — the delete dialog renders the record inside a box it already owns.
80
+ */
81
+ asPage?: boolean;
82
+ /** Padding preset, matching the form's. Only read when `asPage`. */
83
+ openEntityMode?: "side_panel" | "full_screen" | "split" | "dialog";
84
+ /**
85
+ * Needed only to render `additionalFields`, which are arbitrary components
86
+ * that take a form context. Without one those entries are left out rather
87
+ * than rendered against a fake context.
88
+ */
89
+ formContext?: FormContext<M>;
18
90
  }
19
91
 
20
92
  /**
21
93
  * The read-only rendering of a record's values.
22
94
  *
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.
95
+ * This is the same layout as {@link EntityForm}: the sections, the grid spans,
96
+ * the labels and the rail all come from the one resolver, so the record does
97
+ * not rearrange itself when you press Edit. Only the controls differ — each
98
+ * field renders its {@link PropertyPreview} instead of an input.
99
+ *
100
+ * It used to be a flat two-column table of every value in the record: no
101
+ * grouping, no widths, a label column sized for short names and a value column
102
+ * that wrapped emails over two lines while the pane beside it stayed empty. A
103
+ * boolean and a markdown body were given the same room.
104
+ *
105
+ * The synthetic `Id` row it used to prepend is gone with the same reasoning the
106
+ * form applies: between that row, the collection's own id property and the
107
+ * `path/id` chip above them, one UUID appeared three times. The id is a
108
+ * copyable chip in the identity bar, and the rail's record block, once each.
109
+ *
110
+ * The labels are the form's labels, icon and all: a field is the same field
111
+ * whether or not you are editing it. What `mode: "read"` drops is the per-field
112
+ * *description*, which is input help rather than part of the label — and what it
113
+ * adds is room. A control is a bordered box that separates itself from the next
114
+ * one; a value is bare text, so the rows and sections are opened up rather than
115
+ * inheriting spacing sized for inputs.
28
116
  */
29
117
  export function EntityViewBinding<M extends Record<string, unknown>>(
30
118
  {
31
119
  entity,
32
120
  collection,
33
- path,
34
- className
121
+ className,
122
+ asPage = false,
123
+ openEntityMode = "full_screen",
124
+ formContext
35
125
  }: EntityViewBindingProps<M>) {
36
126
 
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 });
127
+ // The record's name, picture, state and labels, lifted out of the grid and
128
+ // into a header. Resolved here rather than inside it because the fields it
129
+ // renders have to come *out* of the layout, and the layout is resolved here.
130
+ const display = useEntityDisplayValues<M>({
131
+ collection,
132
+ entity
133
+ });
134
+
135
+ const headerKeys = useMemo(() => {
136
+ const keys = new Set<string>();
137
+ for (const role of HEADER_DISPLAY_ROLES) {
138
+ // Set only for a role filled by a property of this collection. A
139
+ // role filled by a resolver has no field on the grid to remove.
140
+ const key = display[role].source?.key;
141
+ if (key) keys.add(key);
142
+ }
143
+ return keys;
144
+ }, [display]);
145
+
146
+ // Only *whether* there is a form context changes the field list, not which
147
+ // one it is — so the memo turns on the boolean rather than on the context
148
+ // object, which is rebuilt on every keystroke of the form above it.
149
+ const hasFormContext = Boolean(formContext);
150
+ const fieldKeys = useMemo(
151
+ () => readOnlyFieldKeys(collection as AdminCollection, hasFormContext, headerKeys),
152
+ [collection, hasFormContext, headerKeys]
153
+ );
154
+
155
+ // `status: "existing"` — a record you are reading exists by definition, and
156
+ // that is what routes a generated id and the audit timestamps out of the
157
+ // column and into the record block.
158
+ const layout = useMemo(() => resolveFormLayout({
159
+ collection,
160
+ fieldKeys,
161
+ status: "existing"
162
+ }), [collection, fieldKeys]);
163
+
164
+ const containerRef = useRef<HTMLDivElement>(null);
165
+ // Measured, not a breakpoint — see `useRailVisible`. The same reasoning as
166
+ // the form's: the resolver *moves* fields into the rail, so when the rail
167
+ // cannot be shown they have to fold back into the column.
168
+ const railVisible = useRailVisible(containerRef);
169
+ const showRail = asPage && layout.hasRail && railVisible;
170
+
171
+ const [collapsedSections, setCollapsedSections] = useState<Set<string>>(
172
+ () => new Set(layout.sections.filter(section => section.collapsed).map(section => section.key))
173
+ );
174
+ const toggleSection = useCallback((key: string) => {
175
+ setCollapsedSections(prev => {
176
+ const next = new Set(prev);
177
+ if (next.has(key)) next.delete(key); else next.add(key);
178
+ return next;
179
+ });
180
+ }, []);
181
+
182
+ const renderField = useCallback((field: ResolvedFormField): React.ReactNode => {
183
+
184
+ if (field.additional) {
185
+ const additionalField = collection.additionalFields?.find(f => f.key === field.key);
186
+ if (!additionalField || !formContext) return null;
187
+
188
+ const AdditionalFieldBuilder = additionalField.Builder;
189
+ const additionalFieldContext = formContext as unknown as AdditionalFieldDelegateProps["context"];
190
+ const child = AdditionalFieldBuilder
191
+ ? <AdditionalFieldBuilder entity={entity} context={additionalFieldContext}/>
192
+ : <div className={"w-full"}>
193
+ <AdditionalFieldValue
194
+ field={additionalField}
195
+ entity={entity}
196
+ context={additionalFieldContext}/>
197
+ </div>;
198
+
199
+ return (
200
+ <div key={`additional_${field.key}`} className={spanClass(field.span)}>
201
+ <LabelWithIconAndTooltip
202
+ propertyKey={field.key}
203
+ icon={<AlignLeftIcon size={LABEL_ICON_SIZE}/>}
204
+ title={additionalField.name}
205
+ className={"text-text-secondary dark:text-text-secondary-dark"}/>
206
+ <div className={cls(paperMixin, "w-full min-h-14 p-4 md:p-6 overflow-x-auto no-scrollbar")}>
207
+ <ErrorBoundary>
208
+ {child}
209
+ </ErrorBoundary>
210
+ </div>
211
+ </div>
212
+ );
213
+ }
214
+
215
+ const property = collection.properties?.[field.key] as Property | undefined;
216
+ if (!property) return null;
217
+
218
+ return (
219
+ <div key={`field_${field.key}`} className={spanClass(field.span)}>
220
+ {/* Always labelled from here, unlike the form. The panel editors
221
+ that carry their own header have no header in preview — a map
222
+ previews as a table of its children and an array as its items,
223
+ so leaving the label to them would leave them unlabelled. */}
224
+ <FieldBlock propertyKey={field.key}
225
+ property={property}
226
+ showLabel={true}
227
+ mode={"read"}>
228
+ {/* One control's height for a single-line value, so a row of
229
+ short fields lines up with the taller ones beside it, and
230
+ anything longer simply grows. Matched to the form's row
231
+ rather than shrunk to the text: it is what keeps a value
232
+ from sitting directly on the label of the row below. */}
233
+ <div className={"min-h-8 flex flex-col justify-center min-w-0 text-text-primary dark:text-text-primary-dark"}>
234
+ <PropertyPreview propertyKey={field.key}
235
+ value={getValueInPath(entity.values, field.key)}
236
+ property={property}
237
+ hideLabel
238
+ size={"medium"}/>
239
+ </div>
240
+ </FieldBlock>
241
+ </div>
242
+ );
243
+ }, [collection, entity, formContext]);
244
+
245
+ /**
246
+ * One field of a `readVariant: "summary"` section: a label/value row rather
247
+ * than a grid cell, with the last one emphasised.
248
+ *
249
+ * The last row is the one a summary exists for — a run of figures is read to
250
+ * arrive at its final line — so it is separated by a rule and set at the
251
+ * weight the grid gave every cell equally.
252
+ */
253
+ const renderSummaryField = useCallback((
254
+ field: ResolvedFormField,
255
+ index: number,
256
+ total: number
257
+ ): React.ReactNode => {
258
+
259
+ const property = collection.properties?.[field.key] as Property | undefined;
260
+ if (!property) return null;
261
+
262
+ const last = index === total - 1;
263
+
264
+ return (
265
+ <div key={`summary_${field.key}`}
266
+ className={cls(
267
+ "flex items-baseline justify-between gap-4 min-w-0 py-2",
268
+ last && total > 1 && cls("mt-1.5 pt-3 border-t", defaultBorderMixin)
269
+ )}>
270
+ <span className={cls(
271
+ "min-w-0 truncate",
272
+ last
273
+ ? "text-sm font-semibold text-text-primary dark:text-text-primary-dark"
274
+ : "text-sm text-text-secondary dark:text-text-secondary-dark"
275
+ )}>
276
+ {property.name ?? field.key}
277
+ </span>
278
+ <span className={cls(
279
+ "shrink-0 text-right text-text-primary dark:text-text-primary-dark",
280
+ last ? "text-base font-semibold" : "text-sm"
281
+ )}>
282
+ <PropertyPreview propertyKey={field.key}
283
+ value={getValueInPath(entity.values, field.key)}
284
+ property={property}
285
+ hideLabel
286
+ size={"medium"}/>
287
+ </span>
288
+ </div>
289
+ );
290
+ }, [collection, entity]);
291
+
292
+ // Same fold-back as the form: with no room for the rail its fields become a
293
+ // trailing group rather than disappearing.
294
+ const sections: ResolvedFormSection[] = showRail || !layout.sidebar.length
295
+ ? layout.sections
296
+ : [
297
+ ...layout.sections,
298
+ {
299
+ key: "__rail",
300
+ title: "Settings",
301
+ collapsible: false,
302
+ collapsed: false,
303
+ fields: layout.sidebar
304
+ }
305
+ ];
306
+
307
+ const content = <>
308
+ {/* The title only when nothing above is already showing it. Every
309
+ full-page use sits under an identity bar that carries it; the
310
+ inline ones — the delete dialog — have no bar. */}
311
+ <EntityDisplayHeader entity={entity}
312
+ collection={collection}
313
+ showTitle={!asPage}
314
+ className={"mb-10"}/>
315
+
316
+ <FormSections sections={sections}
317
+ collapsed={collapsedSections}
318
+ onToggle={toggleSection}
319
+ errorKeys={NO_ERRORS}
320
+ renderField={renderField}
321
+ renderSummaryField={renderSummaryField}
322
+ mode={"read"}/>
323
+
324
+ {/* Folded back with the rail's fields, and for the same reason. The rail
325
+ renders only where it has been measured to fit, so on the split pane,
326
+ the side panel and the dialog this block was dropped outright and the
327
+ record's id was nowhere on screen but the truncated chip in the bar.
328
+ An id you cannot read or copy is the one piece of a record you most
329
+ often opened it for. */}
330
+ {!showRail && layout.showRecordMeta && (
331
+ <div className={cls("mt-10 pt-6 border-t max-w-sm", defaultBorderMixin)}>
332
+ <RecordMeta entity={entity as Entity<Record<string, unknown>>}/>
333
+ </div>
334
+ )}
335
+ </>;
336
+
337
+ if (!asPage) {
338
+ // `@container/col` even here: the spans resolve against the column they
339
+ // sit in, and inside a dialog that column is the dialog.
340
+ return (
341
+ <div ref={containerRef} className={cls("@container/col w-full min-w-0", className)}>
342
+ {content}
343
+ </div>
344
+ );
345
+ }
52
346
 
53
347
  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>
348
+ <div ref={containerRef} className={cls("flex-1 flex flex-row w-full min-h-0", className)}>
349
+
350
+ {/* `items-start`: without it the cross-axis default stretches the
351
+ column to the scroller's height and its bottom padding lands at
352
+ the bottom of the viewport instead of after the last field. */}
353
+ <div className={"flex-1 min-w-0 overflow-y-auto flex justify-center items-start"}>
354
+ <div className={cls(
355
+ "@container/col w-full max-w-3xl 2xl:max-w-4xl flex flex-col",
356
+ openEntityMode === "dialog"
357
+ ? "pt-5 pb-8 px-6 sm:px-8"
358
+ : "pt-6 pb-12 px-5 sm:px-8"
359
+ )}>
360
+ {content}
65
361
  </div>
66
- )}
362
+ </div>
67
363
 
68
- <PropertyCollectionView data={entity.values} properties={properties} size={"medium"}/>
364
+ {showRail && <FormRail
365
+ fields={layout.sidebar}
366
+ showRecordMeta={layout.showRecordMeta}
367
+ entity={entity as Entity<Record<string, unknown>>}
368
+ renderField={renderField}/>}
69
369
  </div>
70
370
  );
71
371
  }
@@ -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
@@ -4,17 +4,48 @@ import { ChevronLeftIcon, ErrorBoundary, MenuIcon } from "@rebasepro/ui";
4
4
  import { deepEqual as equal } from "fast-equals"
5
5
 
6
6
  import { useLargeLayout, useAdminModeController, useTranslation } from "@rebasepro/app";
7
+ import { useUrlController } from "../../hooks/navigation/contexts/UrlContext";
7
8
  import { AppContext } from "./useApp";
8
9
 
9
10
  export const DRAWER_WIDTH = 280;
10
11
 
12
+ /**
13
+ * Namespaced by base path: two admins served from one origin are two different
14
+ * navigations, and a single key would have them overwrite each other's drawer.
15
+ */
16
+ export function drawerOpenStorageKey(basePath: string | undefined) {
17
+ return `rebase-drawer-open:${basePath || "/"}`;
18
+ }
19
+
20
+ /** `null` when the user has never toggled the drawer, or storage is unavailable. */
21
+ function readStoredDrawerOpen(key: string): boolean | null {
22
+ if (typeof window === "undefined") return null;
23
+ try {
24
+ const stored = window.localStorage.getItem(key);
25
+ return stored === null ? null : stored === "true";
26
+ } catch (e) {
27
+ // Storage can be blocked: private mode, sandboxed iframe, cookie policy.
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function writeStoredDrawerOpen(key: string, open: boolean) {
33
+ if (typeof window === "undefined") return;
34
+ try {
35
+ window.localStorage.setItem(key, String(open));
36
+ } catch (e) {
37
+ // Same as above: a drawer that forgets is better than a crash.
38
+ }
39
+ }
40
+
11
41
  /**
12
42
  * @group Core
13
43
  */
14
44
  export interface ScaffoldProps {
15
45
 
16
46
  /**
17
- * Open the drawer on hover
47
+ * Expand the collapsed drawer while the pointer is over it. On by default —
48
+ * pass `false` to keep the rail a rail until the user clicks the toggle.
18
49
  */
19
50
  autoOpenDrawer?: boolean;
20
51
 
@@ -22,8 +53,8 @@ export interface ScaffoldProps {
22
53
  * Start with the drawer expanded rather than collapsed to icons.
23
54
  *
24
55
  * Distinct from {@link autoOpenDrawer}, which is a hover behaviour: this one
25
- * only seeds the initial state, so the user's own toggle still wins for the
26
- * rest of the session.
56
+ * only seeds the very first visit. Once the user toggles the drawer, that
57
+ * choice is stored in `localStorage` and wins over this prop on every load.
27
58
  *
28
59
  * Ignored on small layouts, where an expanded drawer covers the content it is
29
60
  * meant to navigate.
@@ -62,7 +93,7 @@ export const Scaffold = React.memo<PropsWithChildren<ScaffoldProps>>(
62
93
 
63
94
  const {
64
95
  children,
65
- autoOpenDrawer,
96
+ autoOpenDrawer = true,
66
97
  defaultDrawerOpen = false,
67
98
  logo,
68
99
  className,
@@ -83,25 +114,96 @@ export const Scaffold = React.memo<PropsWithChildren<ScaffoldProps>>(
83
114
  const includeDrawer = drawerChildren.length > 0;
84
115
  const largeLayout = useLargeLayout();
85
116
 
117
+ const storageKey = drawerOpenStorageKey(useUrlController().basePath);
118
+
86
119
  // Seeded once, deliberately: a `useEffect` syncing this to the prop would
87
120
  // re-expand the drawer under a user who had just collapsed it.
88
- const [drawerOpen, setDrawerOpen] = React.useState(defaultDrawerOpen && largeLayout);
121
+ const [drawerOpen, setDrawerOpenState] = React.useState(defaultDrawerOpen && largeLayout);
89
122
  const [onHover, setOnHover] = React.useState(false);
90
123
 
91
- const setOnHoverTrue = useCallback(() => setOnHover(true), []);
124
+ // The stored choice is a client-only fact, so it is applied after the first
125
+ // render rather than seeded into it: reading storage while rendering would
126
+ // make the client disagree with server-rendered HTML. A layout effect runs
127
+ // before paint, so the drawer still arrives in its remembered state.
128
+ // `defaultDrawerOpen` seeds the very first visit and is ignored after that.
129
+ React.useLayoutEffect(() => {
130
+ const stored = readStoredDrawerOpen(storageKey);
131
+ if (stored === null) return;
132
+ setDrawerOpenState(stored && largeLayout);
133
+ // Deliberately not re-run on `largeLayout`: crossing the breakpoint is
134
+ // the effect below, and re-running here would undo a user's toggle.
135
+ // eslint-disable-next-line react-hooks/exhaustive-deps
136
+ }, [storageKey]);
137
+
138
+ // One piece of state drives two different things: the expanded rail on
139
+ // large layouts, and the modal sheet on small ones. Carrying an expanded
140
+ // rail across the breakpoint would drop that sheet, overlay and all, over
141
+ // the content — so the crossing resets it, and widening again restores
142
+ // whatever the user last chose.
143
+ const wasLargeLayout = React.useRef(largeLayout);
144
+ React.useEffect(() => {
145
+ if (wasLargeLayout.current === largeLayout) return;
146
+ wasLargeLayout.current = largeLayout;
147
+ setDrawerOpenState(largeLayout ? (readStoredDrawerOpen(storageKey) ?? defaultDrawerOpen) : false);
148
+ }, [largeLayout, defaultDrawerOpen, storageKey]);
149
+
150
+ const setDrawerOpen = useCallback((open: boolean) => {
151
+ setDrawerOpenState(open);
152
+ // Small layouts render the drawer as a modal sheet. Remembering that
153
+ // one would greet the next load with an overlay over the content.
154
+ if (!largeLayout) return;
155
+ writeStoredDrawerOpen(storageKey, open);
156
+ }, [largeLayout, storageKey]);
157
+
158
+ // The pointer has left, but a popover the drawer opened is still up, so the
159
+ // collapse is owed rather than cancelled. See `setOnHoverFalse`.
160
+ const collapseWhenPopoverCloses = React.useRef(false);
161
+
162
+ const setOnHoverTrue = useCallback(() => {
163
+ // Hover expansion is the default; `autoOpenDrawer={false}` opts out for
164
+ // admins that want the rail to stay a rail until the toggle is clicked.
165
+ if (!autoOpenDrawer) return;
166
+ collapseWhenPopoverCloses.current = false;
167
+ setOnHover(true);
168
+ }, [autoOpenDrawer]);
92
169
  const setOnHoverFalse = useCallback(() => {
93
- // Don't collapse the drawer while a popover/dropdown is open
94
- if (document.querySelector("[data-radix-popper-content-wrapper]")) return;
170
+ // Don't collapse the drawer out from under an open popover/dropdown —
171
+ // its content is portalled outside the drawer, so reaching for it reads
172
+ // as a mouseleave. The collapse is owed until that popover closes.
173
+ if (document.querySelector("[data-radix-popper-content-wrapper]")) {
174
+ collapseWhenPopoverCloses.current = true;
175
+ return;
176
+ }
177
+ collapseWhenPopoverCloses.current = false;
95
178
  setOnHover(false);
96
179
  }, []);
97
180
 
181
+ // Nothing fires a second mouseleave when the popover finally closes, so the
182
+ // owed collapse has to be noticed rather than waited for. Only mounted
183
+ // while the drawer is actually floating open.
184
+ React.useEffect(() => {
185
+ if (!onHover) return;
186
+ const collapseIfOwed = () => {
187
+ if (!collapseWhenPopoverCloses.current) return;
188
+ if (document.querySelector("[data-radix-popper-content-wrapper]")) return;
189
+ collapseWhenPopoverCloses.current = false;
190
+ setOnHover(false);
191
+ };
192
+ const observer = new MutationObserver(collapseIfOwed);
193
+ observer.observe(document.body, {
194
+ childList: true,
195
+ subtree: true
196
+ });
197
+ return () => observer.disconnect();
198
+ }, [onHover]);
199
+
98
200
  const handleDrawerOpen = useCallback(() => {
99
201
  setDrawerOpen(true);
100
- }, []);
202
+ }, [setDrawerOpen]);
101
203
 
102
204
  const handleDrawerClose = useCallback(() => {
103
205
  setDrawerOpen(false);
104
- }, []);
206
+ }, [setDrawerOpen]);
105
207
 
106
208
  const computedDrawerOpen: boolean = drawerOpen;
107
209
  const computedDrawerHovered = Boolean(largeLayout && onHover);