@rebasepro/admin 0.13.0 → 0.13.1-canary.gcd6689e

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 (60) hide show
  1. package/dist/{CollectionEditorDialog-CjXkWZ64.js → CollectionEditorDialog-CZZZpYOz.js} +3 -3
  2. package/dist/{CollectionEditorDialog-CjXkWZ64.js.map → CollectionEditorDialog-CZZZpYOz.js.map} +1 -1
  3. package/dist/{PropertyEditView-W8I3YV89.js → PropertyEditView-CeoHSmUg.js} +2 -2
  4. package/dist/{PropertyEditView-W8I3YV89.js.map → PropertyEditView-CeoHSmUg.js.map} +1 -1
  5. package/dist/{RouterCollectionsStudioView-BsLTNoqX.js → RouterCollectionsStudioView-CMKFWM2J.js} +4 -4
  6. package/dist/{RouterCollectionsStudioView-BsLTNoqX.js.map → RouterCollectionsStudioView-CMKFWM2J.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/SlotValue.d.ts +29 -0
  10. package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +5 -3
  11. package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +12 -0
  12. package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +29 -4
  13. package/dist/components/DetailViewBinding.d.ts +6 -0
  14. package/dist/components/EditViewBinding.d.ts +7 -1
  15. package/dist/components/EntityIdentityBar.d.ts +10 -1
  16. package/dist/components/EntityViewBinding.d.ts +41 -6
  17. package/dist/components/app/Scaffold.d.ts +4 -3
  18. package/dist/{export-J79rMoNj.js → export-l19WevKo.js} +2 -2
  19. package/dist/{export-J79rMoNj.js.map → export-l19WevKo.js.map} +1 -1
  20. package/dist/form/components/FieldBlock.d.ts +16 -0
  21. package/dist/{history-D7L_JlLO.js → history-JL1ZmSKD.js} +4 -4
  22. package/dist/{history-D7L_JlLO.js.map → history-JL1ZmSKD.js.map} +1 -1
  23. package/dist/hooks/useEntityDisplay.d.ts +102 -0
  24. package/dist/hooks/useEntityDisplayTitle.d.ts +18 -9
  25. package/dist/{import-BvYl--U4.js → import-DNrKrFQV.js} +3 -3
  26. package/dist/{import-BvYl--U4.js.map → import-DNrKrFQV.js.map} +1 -1
  27. package/dist/index.js +79 -15
  28. package/dist/index.js.map +1 -1
  29. package/dist/preview/components/BooleanPreview.d.ts +3 -1
  30. package/dist/routes/RebaseRoute.d.ts +1 -1
  31. package/dist/types/components/PropertyPreviewProps.d.ts +7 -0
  32. package/dist/{util-GiwPgxnL.js → util-pQ3YwBve.js} +1022 -641
  33. package/dist/util-pQ3YwBve.js.map +1 -0
  34. package/package.json +9 -9
  35. package/src/components/AdditionalFieldValue.tsx +68 -0
  36. package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +5 -6
  37. package/src/components/CollectionViewBinding/BoardCardBinding.tsx +9 -27
  38. package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +11 -24
  39. package/src/components/CollectionViewBinding/EntityCardBinding.tsx +10 -33
  40. package/src/components/CollectionViewBinding/SlotValue.tsx +52 -0
  41. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +7 -5
  42. package/src/components/CollectionViewBinding/SplitListShowButton.tsx +28 -0
  43. package/src/components/CollectionViewBinding/usePreviewSlots.ts +115 -48
  44. package/src/components/DetailViewBinding.tsx +71 -54
  45. package/src/components/EditViewBinding.tsx +51 -33
  46. package/src/components/EntityIdentityBar.tsx +26 -4
  47. package/src/components/EntityPreviewBinding.tsx +24 -12
  48. package/src/components/EntityViewBinding.tsx +237 -39
  49. package/src/components/InlineEntityPreview.tsx +10 -10
  50. package/src/components/app/Scaffold.tsx +48 -7
  51. package/src/form/EntityForm.tsx +6 -24
  52. package/src/form/components/FieldBlock.tsx +24 -0
  53. package/src/hooks/navigation/useBuildCollectionRegistryController.tsx +29 -5
  54. package/src/hooks/useEntityDisplay.tsx +258 -0
  55. package/src/hooks/useEntityDisplayTitle.tsx +27 -38
  56. package/src/preview/PropertyPreview.tsx +1 -1
  57. package/src/preview/components/BooleanPreview.tsx +5 -2
  58. package/src/routes/RebaseRoute.tsx +62 -4
  59. package/src/types/components/PropertyPreviewProps.tsx +8 -0
  60. package/dist/util-GiwPgxnL.js.map +0 -1
@@ -25,7 +25,8 @@ import {
25
25
  import { useAnalyticsController } from "@rebasepro/app";
26
26
  import { IconForView } from "@rebasepro/app";
27
27
  import { getPropertyInPath } from "../util/property_utils";
28
- import { getEntityPreviewKeys, getEntityTitlePropertyKeyForEntity } from "../util/previews";
28
+ import { getEntityPreviewKeys } from "../util/previews";
29
+ import { useEntityTitle } from "../hooks/useEntityDisplay";
29
30
  import { getValueInPath } from "@rebasepro/utils";
30
31
  import { useCollectionRegistryController } from "../hooks/navigation/contexts/CollectionRegistryContext";
31
32
  import { useSidePanel } from "../hooks/useSidePanel";
@@ -87,6 +88,13 @@ export function EntityPreviewBindingData({
87
88
 
88
89
  const collection = collectionProp ?? collectionRegistryController.getCollection(entity.path);
89
90
 
91
+ // Above the `!collection` return, because hooks are. `useEntityTitle`
92
+ // tolerates a missing collection for exactly this reason.
93
+ const title = useEntityTitle({
94
+ collection,
95
+ entity
96
+ });
97
+
90
98
  const listProperties = useMemo(() => {
91
99
  if (!collection) return [];
92
100
  return previewKeys ?? getEntityPreviewKeys(authController, collection, customizationController.propertyConfigs, previewKeys, size === "medium" || size === "large" ? 3 : 2);
@@ -100,7 +108,7 @@ export function EntityPreviewBindingData({
100
108
  );
101
109
  }
102
110
 
103
- const titleProperty = includeTitle ? getEntityTitlePropertyKeyForEntity(collection, entity.values, entity.id) : undefined;
111
+ const titleProperty = includeTitle ? title.source?.key : undefined;
104
112
  const imagePropertyKey = includeImage ? getEntityImagePreviewPropertyKey(collection) : undefined;
105
113
  const imageProperty = imagePropertyKey ? collection.properties[imagePropertyKey] : undefined;
106
114
  const ofProp = imageProperty && "of" in imageProperty ? imageProperty.of : undefined;
@@ -148,20 +156,24 @@ export function EntityPreviewBindingData({
148
156
  </div>
149
157
  : <Skeleton/>)}
150
158
 
151
- {titleProperty && (
159
+ {/* A title that came from a property keeps that property's own
160
+ rendering — an enum stays its coloured chip, a relation stays
161
+ a link. A computed one is text, because that is all a
162
+ resolver returns. */}
163
+ {includeTitle && (titleProperty || title.value) && (
152
164
  <div
153
165
  className={"truncate my-0.5 text-sm font-medium text-text-primary dark:text-text-primary-dark"}>
154
- {
155
- entity
166
+ {!entity && titleProperty
167
+ ? <SkeletonPropertyComponent
168
+ property={getPropertyInPath(collection.properties, titleProperty) as Property}
169
+ size={"medium"}/>
170
+ : titleProperty
156
171
  ? <PropertyPreview
157
- propertyKey={titleProperty as string}
158
- value={getValueInPath(entity.values, titleProperty) as never}
159
- property={collection.properties[titleProperty as string] as Property}
160
- size={"medium"}/>
161
- : <SkeletonPropertyComponent
162
- property={collection.properties[titleProperty as string] as Property}
172
+ propertyKey={titleProperty}
173
+ value={title.source?.value as never}
174
+ property={getPropertyInPath(collection.properties, titleProperty) as Property}
163
175
  size={"medium"}/>
164
- }
176
+ : title.value}
165
177
  </div>
166
178
  )}
167
179
 
@@ -1,11 +1,50 @@
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, CustomizationController } 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
+ ErrorBoundary,
12
+ ExternalLinkIcon,
13
+ IconButton,
14
+ iconSize,
15
+ paperMixin,
16
+ Tooltip
17
+ } from "@rebasepro/ui";
18
+ import { getFormFieldKeys, resolveFormLayout, useCustomizationController } from "@rebasepro/app";
19
+ import { getValueInPath } from "@rebasepro/utils";
20
+
21
+ import { FieldBlock, spanClass } from "../form/components/FieldBlock";
22
+ import { AdditionalFieldValue } from "./AdditionalFieldValue";
23
+ import { FormSections } from "../form/components/FormSections";
24
+ import { FormRail } from "../form/components/FormRail";
25
+ import { useRailVisible } from "../form/components/useRailVisible";
26
+ import { LabelWithIconAndTooltip } from "../form/components/LabelWithIconAndTooltip";
27
+ import { PropertyPreview } from "../preview";
28
+
29
+ /** Nothing can be in error in a view that cannot be submitted. */
30
+ const NO_ERRORS: ReadonlySet<string> = new Set<string>();
31
+
32
+ /**
33
+ * The fields the read-only view lays out, in render order.
34
+ *
35
+ * The same set the form uses, minus the `additionalFields` when there is no
36
+ * form context to hand them: those entries are arbitrary components, not
37
+ * values, and one rendered against no context is nothing at all. They are
38
+ * dropped *before* the layout resolves rather than skipped while rendering,
39
+ * because the resolver has by then given each of them a full row — skipping
40
+ * later leaves that row as a hole in the grid.
41
+ */
42
+ export function readOnlyFieldKeys(collection: AdminCollection, hasFormContext: boolean): string[] {
43
+ const keys = getFormFieldKeys(collection);
44
+ if (hasFormContext) return keys;
45
+ const additional = new Set((collection.additionalFields ?? []).map(field => field.key));
46
+ return keys.filter(key => !additional.has(key));
47
+ }
9
48
 
10
49
  /**
11
50
  * @group Components
@@ -15,57 +54,216 @@ export interface EntityViewBindingProps<M extends Record<string, unknown>> {
15
54
  collection: AdminCollection<M>;
16
55
  path: string;
17
56
  className?: string;
57
+ /**
58
+ * Lay the record out as a page: a centred, scrolling column with the
59
+ * metadata rail beside it, exactly as the form does. Off for the inline
60
+ * uses — the delete dialog renders the record inside a box it already owns.
61
+ */
62
+ asPage?: boolean;
63
+ /** Padding preset, matching the form's. Only read when `asPage`. */
64
+ openEntityMode?: "side_panel" | "full_screen" | "split" | "dialog";
65
+ /**
66
+ * Needed only to render `additionalFields`, which are arbitrary components
67
+ * that take a form context. Without one those entries are left out rather
68
+ * than rendered against a fake context.
69
+ */
70
+ formContext?: FormContext<M>;
18
71
  }
19
72
 
20
73
  /**
21
74
  * The read-only rendering of a record's values.
22
75
  *
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.
76
+ * This is the same layout as {@link EntityForm}: the sections, the grid spans,
77
+ * the labels and the rail all come from the one resolver, so the record does
78
+ * not rearrange itself when you press Edit. Only the controls differ each
79
+ * field renders its {@link PropertyPreview} instead of an input.
80
+ *
81
+ * It used to be a flat two-column table of every value in the record: no
82
+ * grouping, no widths, a label column sized for short names and a value column
83
+ * that wrapped emails over two lines while the pane beside it stayed empty. A
84
+ * boolean and a markdown body were given the same room.
85
+ *
86
+ * The synthetic `Id` row it used to prepend is gone with the same reasoning the
87
+ * form applies: between that row, the collection's own id property and the
88
+ * `path/id` chip above them, one UUID appeared three times. The id is a
89
+ * copyable chip in the identity bar, and the rail's record block, once each.
28
90
  */
29
91
  export function EntityViewBinding<M extends Record<string, unknown>>(
30
92
  {
31
93
  entity,
32
94
  collection,
33
- path,
34
- className
95
+ className,
96
+ asPage = false,
97
+ openEntityMode = "full_screen",
98
+ formContext
35
99
  }: EntityViewBindingProps<M>) {
36
100
 
37
101
  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
102
  const externalLink = customizationController?.entityLinkBuilder?.({ entity });
52
103
 
104
+ // Only *whether* there is a form context changes the field list, not which
105
+ // one it is — so the memo turns on the boolean rather than on the context
106
+ // object, which is rebuilt on every keystroke of the form above it.
107
+ const hasFormContext = Boolean(formContext);
108
+ const fieldKeys = useMemo(
109
+ () => readOnlyFieldKeys(collection as AdminCollection, hasFormContext),
110
+ [collection, hasFormContext]
111
+ );
112
+
113
+ // `status: "existing"` — a record you are reading exists by definition, and
114
+ // that is what routes a generated id and the audit timestamps out of the
115
+ // column and into the record block.
116
+ const layout = useMemo(() => resolveFormLayout({
117
+ collection,
118
+ fieldKeys,
119
+ status: "existing"
120
+ }), [collection, fieldKeys]);
121
+
122
+ const containerRef = useRef<HTMLDivElement>(null);
123
+ // Measured, not a breakpoint — see `useRailVisible`. The same reasoning as
124
+ // the form's: the resolver *moves* fields into the rail, so when the rail
125
+ // cannot be shown they have to fold back into the column.
126
+ const railVisible = useRailVisible(containerRef);
127
+ const showRail = asPage && layout.hasRail && railVisible;
128
+
129
+ const [collapsedSections, setCollapsedSections] = useState<Set<string>>(
130
+ () => new Set(layout.sections.filter(section => section.collapsed).map(section => section.key))
131
+ );
132
+ const toggleSection = useCallback((key: string) => {
133
+ setCollapsedSections(prev => {
134
+ const next = new Set(prev);
135
+ if (next.has(key)) next.delete(key); else next.add(key);
136
+ return next;
137
+ });
138
+ }, []);
139
+
140
+ const renderField = useCallback((field: ResolvedFormField): React.ReactNode => {
141
+
142
+ if (field.additional) {
143
+ const additionalField = collection.additionalFields?.find(f => f.key === field.key);
144
+ if (!additionalField || !formContext) return null;
145
+
146
+ const AdditionalFieldBuilder = additionalField.Builder;
147
+ const additionalFieldContext = formContext as unknown as AdditionalFieldDelegateProps["context"];
148
+ const child = AdditionalFieldBuilder
149
+ ? <AdditionalFieldBuilder entity={entity} context={additionalFieldContext}/>
150
+ : <div className={"w-full"}>
151
+ <AdditionalFieldValue
152
+ field={additionalField}
153
+ entity={entity}
154
+ context={additionalFieldContext}/>
155
+ </div>;
156
+
157
+ return (
158
+ <div key={`additional_${field.key}`} className={spanClass(field.span)}>
159
+ <LabelWithIconAndTooltip
160
+ propertyKey={field.key}
161
+ icon={<AlignLeftIcon size={iconSize.small}/>}
162
+ title={additionalField.name}
163
+ className={"text-text-secondary dark:text-text-secondary-dark"}/>
164
+ <div className={cls(paperMixin, "w-full min-h-14 p-4 md:p-6 overflow-x-auto no-scrollbar")}>
165
+ <ErrorBoundary>
166
+ {child}
167
+ </ErrorBoundary>
168
+ </div>
169
+ </div>
170
+ );
171
+ }
172
+
173
+ const property = collection.properties?.[field.key] as Property | undefined;
174
+ if (!property) return null;
175
+
176
+ return (
177
+ <div key={`field_${field.key}`} className={spanClass(field.span)}>
178
+ {/* Always labelled from here, unlike the form. The panel editors
179
+ that carry their own header have no header in preview — a map
180
+ previews as a table of its children and an array as its items,
181
+ so leaving the label to them would leave them unlabelled. */}
182
+ <FieldBlock propertyKey={field.key}
183
+ property={property}
184
+ showLabel={true}>
185
+ {/* One row's height for a single-line value, so a row of
186
+ short fields lines up with the taller ones beside it, and
187
+ anything longer simply grows. */}
188
+ <div className={"min-h-8 flex flex-col justify-center min-w-0 text-text-primary dark:text-text-primary-dark"}>
189
+ <PropertyPreview propertyKey={field.key}
190
+ value={getValueInPath(entity.values, field.key)}
191
+ property={property}
192
+ hideLabel
193
+ size={"medium"}/>
194
+ </div>
195
+ </FieldBlock>
196
+ </div>
197
+ );
198
+ }, [collection, entity, formContext]);
199
+
200
+ // Same fold-back as the form: with no room for the rail its fields become a
201
+ // trailing group rather than disappearing.
202
+ const sections: ResolvedFormSection[] = showRail || !layout.sidebar.length
203
+ ? layout.sections
204
+ : [
205
+ ...layout.sections,
206
+ {
207
+ key: "__rail",
208
+ title: "Settings",
209
+ collapsible: false,
210
+ collapsed: false,
211
+ fields: layout.sidebar
212
+ }
213
+ ];
214
+
215
+ const content = <>
216
+ {externalLink && (
217
+ <div className={"flex justify-end mb-2"}>
218
+ <Tooltip title={"Open in the live site"}>
219
+ <a href={externalLink} rel={"noopener noreferrer"} target={"_blank"}>
220
+ <IconButton size={"small"}>
221
+ <ExternalLinkIcon/>
222
+ </IconButton>
223
+ </a>
224
+ </Tooltip>
225
+ </div>
226
+ )}
227
+
228
+ <FormSections sections={sections}
229
+ collapsed={collapsedSections}
230
+ onToggle={toggleSection}
231
+ errorKeys={NO_ERRORS}
232
+ renderField={renderField}/>
233
+ </>;
234
+
235
+ if (!asPage) {
236
+ // `@container/col` even here: the spans resolve against the column they
237
+ // sit in, and inside a dialog that column is the dialog.
238
+ return (
239
+ <div ref={containerRef} className={cls("@container/col w-full min-w-0", className)}>
240
+ {content}
241
+ </div>
242
+ );
243
+ }
244
+
53
245
  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>
246
+ <div ref={containerRef} className={cls("flex-1 flex flex-row w-full min-h-0", className)}>
247
+
248
+ {/* `items-start`: without it the cross-axis default stretches the
249
+ column to the scroller's height and its bottom padding lands at
250
+ the bottom of the viewport instead of after the last field. */}
251
+ <div className={"flex-1 min-w-0 overflow-y-auto flex justify-center items-start"}>
252
+ <div className={cls(
253
+ "@container/col w-full max-w-3xl 2xl:max-w-4xl flex flex-col",
254
+ openEntityMode === "dialog"
255
+ ? "pt-5 pb-8 px-6 sm:px-8"
256
+ : "pt-6 pb-12 px-5 sm:px-8"
257
+ )}>
258
+ {content}
65
259
  </div>
66
- )}
260
+ </div>
67
261
 
68
- <PropertyCollectionView data={entity.values} properties={properties} size={"medium"}/>
262
+ {showRail && <FormRail
263
+ fields={layout.sidebar}
264
+ showRecordMeta={layout.showRecordMeta}
265
+ entity={entity as Entity<Record<string, unknown>>}
266
+ renderField={renderField}/>}
69
267
  </div>
70
268
  );
71
269
  }
@@ -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
 
@@ -8,13 +8,37 @@ import { AppContext } from "./useApp";
8
8
 
9
9
  export const DRAWER_WIDTH = 280;
10
10
 
11
+ const DRAWER_OPEN_STORAGE_KEY = "rebase-drawer-open";
12
+
13
+ /** `null` when the user has never toggled the drawer, or storage is unavailable. */
14
+ function readStoredDrawerOpen(): boolean | null {
15
+ if (typeof window === "undefined") return null;
16
+ try {
17
+ const stored = window.localStorage.getItem(DRAWER_OPEN_STORAGE_KEY);
18
+ return stored === null ? null : stored === "true";
19
+ } catch (e) {
20
+ // Storage can be blocked: private mode, sandboxed iframe, cookie policy.
21
+ return null;
22
+ }
23
+ }
24
+
25
+ function writeStoredDrawerOpen(open: boolean) {
26
+ if (typeof window === "undefined") return;
27
+ try {
28
+ window.localStorage.setItem(DRAWER_OPEN_STORAGE_KEY, String(open));
29
+ } catch (e) {
30
+ // Same as above: a drawer that forgets is better than a crash.
31
+ }
32
+ }
33
+
11
34
  /**
12
35
  * @group Core
13
36
  */
14
37
  export interface ScaffoldProps {
15
38
 
16
39
  /**
17
- * Open the drawer on hover
40
+ * Expand the collapsed drawer while the pointer is over it. Off by default:
41
+ * the rail stays a rail until the user clicks the toggle.
18
42
  */
19
43
  autoOpenDrawer?: boolean;
20
44
 
@@ -22,8 +46,8 @@ export interface ScaffoldProps {
22
46
  * Start with the drawer expanded rather than collapsed to icons.
23
47
  *
24
48
  * 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.
49
+ * only seeds the very first visit. Once the user toggles the drawer, that
50
+ * choice is stored in `localStorage` and wins over this prop on every load.
27
51
  *
28
52
  * Ignored on small layouts, where an expanded drawer covers the content it is
29
53
  * meant to navigate.
@@ -85,10 +109,27 @@ export const Scaffold = React.memo<PropsWithChildren<ScaffoldProps>>(
85
109
 
86
110
  // Seeded once, deliberately: a `useEffect` syncing this to the prop would
87
111
  // re-expand the drawer under a user who had just collapsed it.
88
- const [drawerOpen, setDrawerOpen] = React.useState(defaultDrawerOpen && largeLayout);
112
+ // The last toggle wins over `defaultDrawerOpen`, which only seeds the very
113
+ // first visit — after that the stored choice survives reloads.
114
+ const [drawerOpen, setDrawerOpenState] = React.useState(
115
+ () => (readStoredDrawerOpen() ?? defaultDrawerOpen) && largeLayout
116
+ );
89
117
  const [onHover, setOnHover] = React.useState(false);
90
118
 
91
- const setOnHoverTrue = useCallback(() => setOnHover(true), []);
119
+ const setDrawerOpen = useCallback((open: boolean) => {
120
+ setDrawerOpenState(open);
121
+ // Small layouts render the drawer as a modal sheet. Remembering that
122
+ // one would greet the next load with an overlay over the content.
123
+ if (!largeLayout) return;
124
+ writeStoredDrawerOpen(open);
125
+ }, [largeLayout]);
126
+
127
+ const setOnHoverTrue = useCallback(() => {
128
+ // Hover expansion is opt-in. Without this the rail pops open whenever
129
+ // the pointer crosses it, which reads as "the drawer is open again".
130
+ if (!autoOpenDrawer) return;
131
+ setOnHover(true);
132
+ }, [autoOpenDrawer]);
92
133
  const setOnHoverFalse = useCallback(() => {
93
134
  // Don't collapse the drawer while a popover/dropdown is open
94
135
  if (document.querySelector("[data-radix-popper-content-wrapper]")) return;
@@ -97,11 +138,11 @@ export const Scaffold = React.memo<PropsWithChildren<ScaffoldProps>>(
97
138
 
98
139
  const handleDrawerOpen = useCallback(() => {
99
140
  setDrawerOpen(true);
100
- }, []);
141
+ }, [setDrawerOpen]);
101
142
 
102
143
  const handleDrawerClose = useCallback(() => {
103
144
  setDrawerOpen(false);
104
- }, []);
145
+ }, [setDrawerOpen]);
105
146
 
106
147
  const computedDrawerOpen: boolean = drawerOpen;
107
148
  const computedDrawerHovered = Boolean(largeLayout && onHover);
@@ -16,7 +16,8 @@ import type { ResolvedFormField } from "@rebasepro/app";
16
16
  import { Alert, Button, cls, Dialog, DialogActions, DialogContent, DialogTitle, iconSize, paperMixin, Typography } from "@rebasepro/ui";
17
17
  import { Formex, FormexController, useCreateFormex } from "@rebasepro/forms";
18
18
 
19
- import { FieldBlock, isSelfLabellingProperty } from "./components/FieldBlock";
19
+ import { FieldBlock, isSelfLabellingProperty, spanClass } from "./components/FieldBlock";
20
+ import { AdditionalFieldValue } from "../components/AdditionalFieldValue";
20
21
  import { FormRail } from "./components/FormRail";
21
22
  import { FormSections } from "./components/FormSections";
22
23
  import { useRailVisible } from "./components/useRailVisible";
@@ -440,10 +441,10 @@ export function EntityForm<M extends Record<string, unknown>>({
440
441
  const child = AdditionalFieldBuilder
441
442
  ? <AdditionalFieldBuilder entity={entity} context={additionalFieldContext}/>
442
443
  : <div className={"w-full"}>
443
- {additionalField.value?.({
444
- entity,
445
- context: additionalFieldContext
446
- })?.toString()}
444
+ <AdditionalFieldValue
445
+ field={additionalField}
446
+ entity={entity}
447
+ context={additionalFieldContext}/>
447
448
  </div>;
448
449
 
449
450
  return (
@@ -706,25 +707,6 @@ export function EntityForm<M extends Record<string, unknown>>({
706
707
  );
707
708
  }
708
709
 
709
- /**
710
- * Grid placement for a span.
711
- *
712
- * Written out as literal class strings rather than composed, because Tailwind
713
- * scans source text: a template literal would produce classes that exist at
714
- * runtime and were never generated into the stylesheet.
715
- *
716
- * Below the column breakpoint every field takes the single available column, so
717
- * the side panel, the dialog and mobile all get a plain stack.
718
- */
719
- function spanClass(span: 1 | 2 | 3 | 4): string {
720
- switch (span) {
721
- case 1: return "@2xl/col:col-span-1";
722
- case 2: return "@2xl/col:col-span-2";
723
- case 3: return "@2xl/col:col-span-3";
724
- case 4: return "@2xl/col:col-span-4";
725
- }
726
- }
727
-
728
710
  function useOnAutoSave<M extends Record<string, unknown>>(autoSave: undefined | boolean, formex: FormexController<M>, lastSavedValues: React.MutableRefObject<EntityValues<M> | undefined>, save: (values: EntityValues<M>) => Promise<Entity<M> | void>) {
729
711
  useEffect(() => {
730
712
  if (!autoSave) return;
@@ -1,9 +1,33 @@
1
1
  import type { Property } from "@rebasepro/types";
2
+ import type { PropertySpan } from "@rebasepro/admin-types";
2
3
  import React from "react";
3
4
  import { cls, ErrorBoundary, Typography } from "@rebasepro/ui";
4
5
  import { getIconForProperty } from "../../util/property_utils";
5
6
  import { PropertyIdCopyTooltip } from "../../components/PropertyIdCopyTooltip";
6
7
 
8
+ /**
9
+ * Grid placement for a span.
10
+ *
11
+ * Written out as literal class strings rather than composed, because Tailwind
12
+ * scans source text: a template literal would produce classes that exist at
13
+ * runtime and were never generated into the stylesheet.
14
+ *
15
+ * Below the column breakpoint every field takes the single available column, so
16
+ * the side panel, the dialog and mobile all get a plain stack.
17
+ *
18
+ * Lives here rather than in the form, because the read-only rendering of a
19
+ * record places its fields on the same grid — a record that changes shape when
20
+ * you press Edit is the bug this whole layout exists to avoid.
21
+ */
22
+ export function spanClass(span: PropertySpan): string {
23
+ switch (span) {
24
+ case 1: return "@2xl/col:col-span-1";
25
+ case 2: return "@2xl/col:col-span-2";
26
+ case 3: return "@2xl/col:col-span-3";
27
+ case 4: return "@2xl/col:col-span-4";
28
+ }
29
+ }
30
+
7
31
  /**
8
32
  * Does this property's editor supply its own heading?
9
33
  *
@@ -41,11 +41,35 @@ export function useBuildCollectionRegistryController(props: {
41
41
  if (!collectionPath) return undefined;
42
42
 
43
43
  let collection: AdminCollection | undefined;
44
- try {
45
- collection = registry.resolvePathToCollections(collectionPath).finalCollection;
46
- } catch (e) {
47
- console.debug(`Could not resolve path to collection: ${collectionPath}`, e);
48
- return undefined;
44
+
45
+ // An exact slug match first, because a slug is allowed to contain
46
+ // slashes and several drivers need it to: a Firestore collection
47
+ // partitioned by locale is declared as `content/de-DE/podcasts`, and
48
+ // that string is its *name*, not a path to walk.
49
+ //
50
+ // Without this, such a slug went straight to `resolvePathToCollections`,
51
+ // which reads any `a/b/c` as collection/entity/subcollection, looked for
52
+ // a root collection called `content`, found none and threw — and the
53
+ // catch below turned that into `undefined`. `RebaseRoute` renders
54
+ // `null` for an unresolved collection, so the whole view came out blank
55
+ // with nothing in the console above `debug`. Thirty-five collections in
56
+ // one app were unreachable that way.
57
+ // `collectionPath`, not `cleanedPath`: an even segment count means the
58
+ // path ends at a record, and the id has already been trimmed off above.
59
+ // Matching the raw path instead would only ever hit for a collection
60
+ // route, leaving `content/de-DE/podcasts/abc123` back on the walker.
61
+ collection = registry.get(collectionPath) as AdminCollection | undefined;
62
+
63
+ if (!collection) {
64
+ try {
65
+ collection = registry.resolvePathToCollections(collectionPath).finalCollection;
66
+ } catch (e) {
67
+ // Warn, not debug. An unresolved collection ends as a route with
68
+ // nothing in it, and at `debug` there was no way to tell that
69
+ // from a collection that legitimately has no rows.
70
+ console.warn(`[rebase] Could not resolve a collection for "${collectionPath}"`, e);
71
+ return undefined;
72
+ }
49
73
  }
50
74
 
51
75
  if (!collection) {