@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
@@ -3,12 +3,14 @@ import type { Property, RelationProperty } from "@rebasepro/types";
3
3
  import type { Entity, EntityRelation } from "@rebasepro/types";
4
4
  import type { PropertyConfig, AdminCollection } from "@rebasepro/admin-types";
5
5
  import { getEntityImagePreviewPropertyKey } from "@rebasepro/app";
6
+ import { getDisplayPropertyKey } from "@rebasepro/app";
6
7
  import { getLeadingRelationTitleKey, getTitlePropertyCandidates, looksLikeIdentifierValue } from "@rebasepro/app";
7
8
  import { getEntityFromCache } from "@rebasepro/app";
8
9
  import { getEntityPreviewKeys } from "../../util/previews";
9
10
  import { getValueInPath } from "@rebasepro/utils";
10
11
  import type { AuthController } from "@rebasepro/admin-types";
11
12
  import { ChipColorScheme, CHIP_COLORS, CHIP_SEED_KEYS } from "@rebasepro/ui";
13
+ import { useEntityDisplay } from "../../hooks/useEntityDisplay";
12
14
 
13
15
  /** Whether an authored relation yields many rows. Derived from its kind. */
14
16
  function relationCardinality(relation: { kind?: string; cardinality?: string } | undefined): "one" | "many" | undefined {
@@ -23,12 +25,18 @@ function relationCardinality(relation: { kind?: string; cardinality?: string } |
23
25
  // ── Slot types ────────────────────────────────────────────────────────
24
26
 
25
27
  /**
26
- * A resolved "slot" containing the property definition, key, and the
27
- * concrete value extracted from a entity.
28
+ * A resolved "slot": what fills one display role for one record.
29
+ *
30
+ * `property` and `propertyKey` are set when the value was read off the record,
31
+ * and absent when a `display` resolver computed it — a computed title has no
32
+ * column behind it, so there is nothing to render it *as*. Renderers must go
33
+ * through {@link SlotValue}, which makes that choice once: a property renders
34
+ * with its own preview (an enum keeps its coloured chip, a date its format), a
35
+ * computed value renders as text.
28
36
  */
29
37
  export interface PreviewSlot {
30
- property: Property;
31
- propertyKey: string;
38
+ property?: Property;
39
+ propertyKey?: string;
32
40
  value: unknown;
33
41
  }
34
42
 
@@ -129,10 +137,19 @@ export function resolveCollectionSlotKeys(
129
137
  ? [leadingRelationKey, ...rankedCandidates.filter(key => key !== leadingRelationKey)]
130
138
  : rankedCandidates;
131
139
  const titleKey = titleKeyCandidates[0];
140
+ // `getEntityImagePreviewPropertyKey` prefers `display.image` itself, so
141
+ // every caller of it — this, the reference card, the board — agrees.
132
142
  const imageKey = getEntityImagePreviewPropertyKey(collection);
133
143
 
134
- // Status: first string-enum that isn't the title
135
- let statusKey: string | undefined;
144
+ // Status: declared, else the first string-enum that isn't the title.
145
+ //
146
+ // Everything below this line is inference — "the first enum", "a key that
147
+ // looks like a timestamp", "the longest remaining string". It is a good
148
+ // guess and it is only a guess, so a collection that states the role wins
149
+ // outright and the ladder is skipped. That is the whole point of
150
+ // `admin.display`: the heuristics stay for the collections that never say
151
+ // anything, and stop overruling the ones that do.
152
+ let statusKey: string | undefined = getDisplayPropertyKey(collection, "status");
136
153
 
137
154
  // 1. Explicitly defined in previewProperties
138
155
  if (!statusKey && collection.previewProperties) {
@@ -169,13 +186,15 @@ export function resolveCollectionSlotKeys(
169
186
  }
170
187
  }
171
188
 
172
- // Date: prefer well-known timestamp fields, fallback to any date
173
- let dateKey: string | undefined;
189
+ // Date: declared, else a well-known timestamp field, else any date
190
+ let dateKey: string | undefined = getDisplayPropertyKey(collection, "date");
174
191
  const dateCandidates = ["updated_at", "updatedAt", "modified_at", "modifiedAt", "created_at", "createdAt"];
175
- for (const candidate of dateCandidates) {
176
- if (collection.properties[candidate]) {
177
- dateKey = candidate;
178
- break;
192
+ if (!dateKey) {
193
+ for (const candidate of dateCandidates) {
194
+ if (collection.properties[candidate]) {
195
+ dateKey = candidate;
196
+ break;
197
+ }
179
198
  }
180
199
  }
181
200
  if (!dateKey) {
@@ -222,7 +241,8 @@ export function resolveCollectionSlotKeys(
222
241
  const scoreB = propB?.type === "string" ? (propB.admin?.multiline ? 2 : 1) : 0;
223
242
  return scoreB - scoreA;
224
243
  });
225
- const subtitleKey = sortedPreviewKeys.length > 0 ? sortedPreviewKeys[0] : undefined;
244
+ const subtitleKey = getDisplayPropertyKey(collection, "subtitle")
245
+ ?? (sortedPreviewKeys.length > 0 ? sortedPreviewKeys[0] : undefined);
226
246
 
227
247
  return { titleKey,
228
248
  titleKeyCandidates,
@@ -506,43 +526,26 @@ function resolveRelationDisplayName(
506
526
  }
507
527
 
508
528
  // Helper: extract display name from entity values using the target collection
529
+ //
530
+ // This used to carry a ranking of its own — priority keys `name/title/label/
531
+ // displayName`, then the first visible non-id string — which is a *seventh*
532
+ // answer to "what is this record called", and one that disagreed with the
533
+ // shared one: it read `titleProperty` with a flat lookup (so a dotted path
534
+ // was ignored), never saw `display.title`, and ranked by key name where the
535
+ // shared ranking excludes identifiers structurally. A chip and the record it
536
+ // points at could therefore be labelled differently on the same screen.
537
+ //
538
+ // What is left here is the part that is genuinely local: a relation carries
539
+ // *eagerly loaded* values, not an entity, so a resolver cannot run against
540
+ // it — a computed title falls back to the ranked property, which is the same
541
+ // thing the list row shows while its own resolver is in flight.
509
542
  const extractDisplayName = (values: Record<string, unknown>): string | undefined => {
510
543
  if (targetCollection) {
511
- const targetTitleKey = targetCollection.titleProperty as string | undefined;
512
- if (targetTitleKey && values[targetTitleKey] !== undefined) {
513
- return String(values[targetTitleKey]);
514
- }
515
-
516
- // Helper to check if a property is hidden/internal
517
- const isHiddenProp = (p: Property): boolean => {
518
- if (p.admin?.hideFromCollection) return true;
519
- if (typeof p.admin?.disabled === "object" && p.admin.disabled.hidden) return true;
520
- return false;
521
- };
522
-
523
- // Helper to check if a property is a visible, non-id string
524
- const isDisplayCandidate = (p: Property): boolean => {
525
- return p.type === "string" && !p.admin?.multiline && !p.admin?.markdown && !p.storage
526
- && !("isId" in p && p.isId) && !isHiddenProp(p);
527
- };
528
-
529
- // Prioritize common title-like fields: name, title, label, displayName
530
- const priorityKeys = ["name", "title", "label", "displayName"];
531
- for (const pk of priorityKeys) {
532
- const p = targetCollection.properties[pk] as Property | undefined;
533
- if (p && isDisplayCandidate(p) && values[pk] !== undefined && values[pk] !== null) {
534
- return String(values[pk]);
535
- }
536
- }
537
-
538
- // Fallback: find first visible, non-id string property in target
539
- for (const [k, p] of Object.entries(targetCollection.properties)) {
540
- const tp = p as Property;
541
- if (isDisplayCandidate(tp)) {
542
- if (values[k] !== undefined && values[k] !== null) {
543
- return String(values[k]);
544
- }
545
- }
544
+ for (const key of getTitlePropertyCandidates(targetCollection)) {
545
+ const value = getValueInPath(values, key);
546
+ if (value === undefined || value === null || value === "") continue;
547
+ if (typeof value === "string" && looksLikeIdentifierValue(value)) continue;
548
+ return String(value);
546
549
  }
547
550
  }
548
551
  // Generic fallback: walk entity values for any short string.
@@ -582,3 +585,67 @@ function resolveRelationDisplayName(
582
585
  // 3. Final fallback: show the ID
583
586
  return id !== undefined ? String(id) : undefined;
584
587
  }
588
+
589
+ /**
590
+ * The slots for one record, with `admin.display` resolvers applied.
591
+ *
592
+ * {@link resolveEntitySlots} answers the question from values already in hand —
593
+ * which is everything a slot filled by a property needs, and nothing a slot
594
+ * filled by a *resolver* can use. A computed title may have to be fetched, so it
595
+ * arrives after the first render; this hook is where the two meet.
596
+ *
597
+ * Until a resolver lands, the derived slot shows. That is deliberate: a list of
598
+ * fifty rows must not blank its titles for a beat while fifty promises settle,
599
+ * and the derived answer — the best-ranked property carrying a readable value —
600
+ * is a reasonable thing to be looking at meanwhile. When the resolved value
601
+ * arrives it replaces the slot outright, property and all, because there is no
602
+ * property behind a computed value (see {@link PreviewSlot}).
603
+ *
604
+ * Every role is hooked unconditionally, so the hook order is fixed regardless of
605
+ * what a given collection declares.
606
+ */
607
+ export function useEntitySlots(
608
+ entity: Entity<Record<string, unknown>>,
609
+ collection: AdminCollection<Record<string, unknown>>,
610
+ slotKeys: CollectionSlotKeys
611
+ ): EntityPreviewSlots {
612
+
613
+ const derived = useMemo(
614
+ () => resolveEntitySlots(entity, collection, slotKeys),
615
+ [entity, collection, slotKeys]
616
+ );
617
+
618
+ const params = { collection,
619
+ entity };
620
+ const title = useEntityDisplay<string>("title", params);
621
+ const subtitle = useEntityDisplay<string>("subtitle", params);
622
+ const image = useEntityDisplay<string>("image", params);
623
+ const status = useEntityDisplay<string>("status", params);
624
+ const date = useEntityDisplay<Date | string | number>("date", params);
625
+
626
+ return useMemo(() => {
627
+ // `source` is set only when the value came from a property, so its
628
+ // absence is exactly the "this was computed" signal — no second flag to
629
+ // keep in step with the first.
630
+ const computed = (
631
+ resolved: { value: unknown, source?: unknown }
632
+ ): PreviewSlot | undefined =>
633
+ resolved.value !== undefined && resolved.source === undefined
634
+ ? { value: resolved.value }
635
+ : undefined;
636
+
637
+ const computedDate = computed(date);
638
+
639
+ return {
640
+ ...derived,
641
+ title: computed(title) ?? derived.title,
642
+ subtitle: computed(subtitle) ?? derived.subtitle,
643
+ image: computed(image) ?? derived.image,
644
+ status: computed(status) ?? derived.status,
645
+ date: computedDate
646
+ ? { ...computedDate,
647
+ formatted: formatDateValue(computedDate.value) }
648
+ : derived.date
649
+ };
650
+ }, [derived, title, subtitle, image, status, date]);
651
+ }
@@ -151,13 +151,10 @@ function AdminNavigationContent() {
151
151
  closeHover
152
152
  } = useApp();
153
153
 
154
- const [adminMenuOpen, setAdminMenuOpen] = React.useState(false);
155
-
156
154
  const analyticsController = useAnalyticsController();
157
155
  const navigationState = useNavigationStateController();
158
156
  const context = useRebaseContext();
159
157
 
160
- const tooltipsOpen = drawerHovered && !drawerOpen && !adminMenuOpen;
161
158
  const largeLayout = useLargeLayout();
162
159
  const adminModeController = useAdminModeController();
163
160
  const registry = useRebaseRegistry();
@@ -251,8 +248,6 @@ context });
251
248
  collapsed={isGroupCollapsed(group)}
252
249
  onToggleCollapsed={() => toggleGroupCollapsed(group)}
253
250
  drawerOpen={drawerVisuallyOpen}
254
- tooltipsOpen={tooltipsOpen}
255
- adminMenuOpen={adminMenuOpen}
256
251
  onItemClick={onItemClick}
257
252
  icon={groupIcons.get(group)}
258
253
  />
@@ -278,7 +273,7 @@ export function DrawerLogo({
278
273
  drawerHovered: boolean;
279
274
  }) {
280
275
 
281
- const showFullContent = drawerOpen || (drawerHovered && !drawerOpen);
276
+ const showFullContent = drawerOpen || drawerHovered;
282
277
 
283
278
  return (
284
279
  <div className="flex flex-row items-center shrink-0 pt-4 pb-0 px-2">
@@ -333,42 +328,43 @@ export function DrawerToggle({
333
328
  }) {
334
329
  const isExpanded = drawerOpen;
335
330
  const isHovered = drawerHovered && !drawerOpen;
336
- const isFloating = isHovered;
337
331
  const showFullContent = isExpanded || isHovered;
338
332
 
333
+ const [wantsTooltip, setWantsTooltip] = React.useState(false);
334
+ // See {@link DrawerNavigationItem}: a masked tooltip never hears the pointer
335
+ // leave, so the stale state is dropped as the mask goes on.
336
+ if (showFullContent && wantsTooltip) setWantsTooltip(false);
337
+
339
338
  const { t } = useTranslation();
340
339
 
341
340
  return (
342
341
  <div className="shrink-0 mt-auto px-4 pt-0.5 pb-2">
343
342
  <Tooltip
344
343
  title={isExpanded ? t("collapse") : t("expand")}
344
+ // The row spells out "Collapse"/"Expand" itself whenever it is wide
345
+ // enough to show the label — expanded, or floating open under the
346
+ // pointer. A tooltip repeating that word is noise; it earns its
347
+ // place only on a bare rail, where the chevron stands alone.
348
+ //
349
+ // Masked, not withheld: see {@link DrawerNavigationItem} for why
350
+ // this stays controlled in both states.
351
+ open={!showFullContent && wantsTooltip}
352
+ onOpenChange={setWantsTooltip}
345
353
  side="right"
346
354
  sideOffset={12}
347
355
  asChild={true}
348
- open={isFloating ? false : undefined}
349
356
  >
350
- <div
357
+ <button
358
+ type="button"
351
359
  className={cls(
352
- "flex flex-row items-center rounded-lg cursor-pointer",
360
+ "flex flex-row items-center rounded-lg cursor-pointer w-full",
353
361
  "hover:bg-surface-accent-100 dark:hover:bg-surface-800",
354
362
  "transition-colors duration-150",
355
363
  "py-2"
356
364
  )}
357
- role="button"
358
- tabIndex={0}
359
365
  aria-expanded={isExpanded}
360
366
  aria-label={isExpanded ? t("collapse") : t("expand")}
361
367
  onClick={() => isExpanded ? closeDrawer() : openDrawer()}
362
- onKeyDown={(e) => {
363
- if (e.key === "Enter" || e.key === " ") {
364
- e.preventDefault();
365
- if (isExpanded) {
366
- closeDrawer();
367
- } else {
368
- openDrawer();
369
- }
370
- }
371
- }}
372
368
  >
373
369
  <div className="shrink-0 flex items-center justify-center w-[44px] h-[24px] text-surface-500 dark:text-surface-400">
374
370
  {isExpanded
@@ -387,7 +383,7 @@ export function DrawerToggle({
387
383
  {isExpanded ? t("collapse") : t("expand")}
388
384
  </Typography>
389
385
  </div>
390
- </div>
386
+ </button>
391
387
  </Tooltip>
392
388
  </div>
393
389
  );
@@ -10,11 +10,11 @@ import { CollectionViewBinding } from "./CollectionViewBinding/CollectionViewBin
10
10
  import { EntityViewBinding } from "./EntityViewBinding";
11
11
  import { EntityIdentityBar } from "./EntityIdentityBar";
12
12
  import { SplitListCloseButton } from "./CollectionViewBinding/SplitListCloseButton";
13
+ import { SplitListShowButton } from "./CollectionViewBinding/SplitListShowButton";
13
14
  import { EntityInspector, InspectorTab } from "./EntityInspector";
14
15
  import { CircularProgressCenter, iconSize } from "@rebasepro/ui";
15
16
  import {
16
17
  Alert,
17
- ArrowLeftIcon,
18
18
  Button,
19
19
  CenteredView,
20
20
  cls,
@@ -34,6 +34,7 @@ import { ErrorBoundary } from "@rebasepro/ui";
34
34
  import { ErrorView, createFormexStub, usePermissions, useTranslation, getIcon } from "@rebasepro/app";
35
35
 
36
36
  import { removeInitialAndTrailingSlashes, resolveDefaultSelectedView } from "@rebasepro/app";
37
+ import { withViewMode } from "../util/view_mode";
37
38
  import { resolvedSelectedEntityView } from "../util/resolutions";
38
39
  import {
39
40
  useCustomizationController,
@@ -45,9 +46,7 @@ import {
45
46
  import { useUrlController } from "../hooks/navigation/contexts/UrlContext";
46
47
  import { useCollectionRegistryController } from "../hooks/navigation/contexts/CollectionRegistryContext";
47
48
  import { useNavigate } from "react-router";
48
- import { getValueInPath } from "@rebasepro/utils";
49
- import { getEntityTitlePropertyKeyForEntity, isUserSelectProperty, resolveTitleToString } from "../util/previews";
50
- import { getUserLabel, useResolvedUser } from "../hooks/useResolvedUsers";
49
+ import { useEntityDisplayTitle } from "../hooks/useEntityDisplayTitle";
51
50
 
52
51
 
53
52
  import { MAIN_TAB_VALUE, JSON_TAB_VALUE, HISTORY_TAB_VALUE } from "../util/view_constants";
@@ -82,6 +81,12 @@ export interface DetailViewBindingProps<M extends Record<string, unknown> = Reco
82
81
  * of the breadcrumb — the side panel's close button.
83
82
  */
84
83
  barActionsStart?: React.ReactNode;
84
+ /**
85
+ * Full screen only: bring the list back beside this record. Set when the
86
+ * record was opened out of a split collection, so folding the list away is
87
+ * reversible; it takes the place of the back arrow when present.
88
+ */
89
+ onShowList?: () => void;
85
90
  }
86
91
 
87
92
  export function DetailViewBinding<M extends Record<string, unknown>>({
@@ -141,7 +146,8 @@ function DetailViewBindingInner<M extends Record<string, unknown>>({
141
146
  dataLoading,
142
147
  layout = "full_screen",
143
148
  barActions,
144
- barActionsStart
149
+ barActionsStart,
150
+ onShowList
145
151
  }: DetailViewBindingProps<M> & {
146
152
  entity?: Entity<M>,
147
153
  dataLoading: boolean,
@@ -283,18 +289,13 @@ entityId }
283
289
  const formViewConfig = (collection as AdminCollection<M> & { formView?: FormViewConfig<M> }).formView;
284
290
  const FormViewBuilder = formViewConfig?.Builder ? resolveComponentRef<EntityCustomViewParams>(formViewConfig.Builder as ComponentRef<EntityCustomViewParams>) : null;
285
291
 
286
- // Title resolution
287
- const titlePropertyKey = getEntityTitlePropertyKeyForEntity(collection, usedEntity?.values, usedEntity?.id);
288
- const rawTitle = usedEntity?.values && titlePropertyKey ? getValueInPath(usedEntity.values, titlePropertyKey) : undefined;
289
- // A user picker stores an id: resolve it to the person, like a relation.
290
- const titleUser = useResolvedUser(isUserSelectProperty(collection, titlePropertyKey) && typeof rawTitle === "string"
291
- ? rawTitle
292
- : undefined);
293
- const title = titleUser
294
- ? getUserLabel(titleUser)
295
- : (rawTitle !== undefined && rawTitle !== null
296
- ? resolveTitleToString(rawTitle)
297
- : (collection.singularName ?? collection.name));
292
+ // The heading, resolved the one way — this used to be a copy of
293
+ // `useEntityDisplayTitle`'s body, and the two had already drifted.
294
+ const title = useEntityDisplayTitle({
295
+ collection,
296
+ entity: usedEntity,
297
+ status: "existing"
298
+ });
298
299
 
299
300
  // Non-action custom views
300
301
  const nonActionCustomViews = useMemo(() =>
@@ -380,20 +381,31 @@ entityId }
380
381
  }, [onTabChange, path, entityId, collection]);
381
382
 
382
383
  const propertyDetailView = () => {
383
- // formView.Builder replaces the default property display
384
+ // formView.Builder replaces the default property display. It gets the
385
+ // centred column the record used to be given here; the default display
386
+ // now lays itself out, rail included.
384
387
  if (FormViewBuilder && usedEntity) {
385
- return <ErrorBoundary>
386
- <Suspense fallback={<CircularProgressCenter />}>
387
- <FormViewBuilder
388
- collection={collection}
389
- parentCollectionSlugs={parentCollectionSlugs}
390
- parentEntityIds={parentEntityIds}
391
- entity={usedEntity}
392
- modifiedValues={usedEntity?.values}
393
- formContext={readOnlyFormContext as FormContext<Record<string, unknown>>}
394
- />
395
- </Suspense>
396
- </ErrorBoundary>;
388
+ return <div className={"flex-1 min-w-0 overflow-y-auto flex justify-center items-start"}>
389
+ <div className={cls(
390
+ "w-full max-w-3xl 2xl:max-w-4xl flex flex-col",
391
+ layout === "dialog"
392
+ ? "pt-5 pb-12 px-6 sm:px-8"
393
+ : "pt-6 pb-16 px-5 sm:px-8"
394
+ )}>
395
+ <ErrorBoundary>
396
+ <Suspense fallback={<CircularProgressCenter />}>
397
+ <FormViewBuilder
398
+ collection={collection}
399
+ parentCollectionSlugs={parentCollectionSlugs}
400
+ parentEntityIds={parentEntityIds}
401
+ entity={usedEntity}
402
+ modifiedValues={usedEntity?.values}
403
+ formContext={readOnlyFormContext as FormContext<Record<string, unknown>>}
404
+ />
405
+ </Suspense>
406
+ </ErrorBoundary>
407
+ </div>
408
+ </div>;
397
409
  }
398
410
 
399
411
  return (
@@ -402,6 +414,9 @@ entityId }
402
414
  entity={usedEntity}
403
415
  collection={collection}
404
416
  path={path}
417
+ asPage
418
+ openEntityMode={layout}
419
+ formContext={readOnlyFormContext}
405
420
  />}
406
421
  </>
407
422
  );
@@ -458,24 +473,27 @@ entityId }
458
473
  );
459
474
  });
460
475
 
476
+ // The breadcrumb's destination, and the back arrow's when it renders. Both
477
+ // keep the view mode: a collection reached from one of its own records must
478
+ // come back as the user left it.
479
+ const collectionUrl = withViewMode(urlController.buildUrlCollectionPath(path));
480
+
461
481
  // Full screen is the one layout with no way out of its own: a side panel and a
462
482
  // dialog can be dismissed, and a split keeps the list beside it, but opening an
463
483
  // entity full screen replaces the collection entirely and left browser Back as
464
484
  // the only route back to it — which is not an affordance the page shows, and is
465
485
  // wrong anyway once the user has moved between tabs inside the entity.
466
- const backToCollectionButton = layout === "full_screen" ? (
467
- <Tooltip title={t("back")}>
468
- <IconButton
469
- size="small"
470
- aria-label={t("back")}
471
- onClick={() => navigate(urlController.buildUrlCollectionPath(path))}
472
- >
473
- <ArrowLeftIcon size={iconSize.smallest} />
474
- </IconButton>
475
- </Tooltip>
476
- ) : null;
477
-
478
- const shouldShowTopBar = Boolean(barActions) || hasAdditionalViews || layout === "side_panel" || layout === "dialog" || Boolean(backToCollectionButton);
486
+ //
487
+ // Unless the list can be unfolded back beside the record: that chevron
488
+ // leads to the same collection while keeping the record open, so it stands
489
+ // alone rather than beside an arrow going almost the same place.
490
+ //
491
+ // It goes to the bar's leading edge — where a back control reads as back,
492
+ // and where the edit view has always put the same arrow — rather than to
493
+ // the trailing edge among the record's own actions.
494
+ const backToCollection = layout === "full_screen" && !onShowList
495
+ ? () => navigate(collectionUrl)
496
+ : undefined;
479
497
 
480
498
  const fullScreenButton = !barActions && // Not in split: the list panel beside it carries the close control,
481
499
  // and an expand button there competes with it.
@@ -508,43 +526,45 @@ entityId }
508
526
  // Main content view. The title, the `path/id` chip and the empty band under
509
527
  // them have moved into the identity bar, and the `w-80 2xl:w-96` rail that
510
528
  // held a single Edit button is gone — the bar holds it now.
529
+ //
530
+ // The scroll now lives on the record's own column rather than on this row,
531
+ // so the metadata rail beside it stays put while the record scrolls — the
532
+ // same arrangement the form uses.
511
533
  const mainView = <div
512
534
  className={cls(
513
- "flex-1 flex flex-row w-full overflow-y-auto justify-center",
535
+ "flex-1 flex flex-row w-full min-h-0",
514
536
  !mainViewVisible ? "hidden" : ""
515
537
  )}>
516
- <div className={cls(
517
- "w-full max-w-3xl 2xl:max-w-4xl flex flex-col",
518
- layout === "dialog"
519
- ? "pt-5 pb-12 px-6 sm:px-8"
520
- : "pt-6 pb-16 px-5 sm:px-8"
521
- )}>
522
- {propertyDetailView()}
523
- </div>
538
+ {propertyDetailView()}
524
539
  </div>;
525
540
 
526
541
  let result = <div className="relative flex flex-col h-full w-full bg-white dark:bg-surface-800">
527
542
 
528
543
  <EntityIdentityBar
529
544
  collection={collection as AdminCollection}
545
+ collectionUrl={layout === "full_screen" || layout === "split" ? collectionUrl : undefined}
530
546
  title={title}
531
547
  entityId={entityId}
532
548
  status={"existing"}
533
549
  dirty={false}
534
550
  saving={false}
551
+ onBack={backToCollection}
535
552
  onInspect={includeJsonView ? () => setInspectorTab("json") : undefined}
536
553
  onViewHistory={includeHistoryView ? () => setInspectorTab("history") : undefined}
554
+ externalLink={usedEntity
555
+ ? customizationController?.entityLinkBuilder?.({ entity: usedEntity })
556
+ : undefined}
537
557
  leading={<>
538
558
  {barActionsStart}
539
559
  {/* Split view: closing the list is opening this record
540
560
  full screen, and the control for it belongs at this
541
- bar's leading edge. */}
561
+ bar's leading edge. Full screen carries its mirror. */}
542
562
  {layout === "split" && <SplitListCloseButton/>}
563
+ {layout === "full_screen" && onShowList && <SplitListShowButton onClick={onShowList}/>}
543
564
  </>}
544
565
  trailing={<>
545
566
  {editButton}
546
567
  {pluginActionsTop}
547
- {backToCollectionButton}
548
568
  {fullScreenButton}
549
569
  {barActions?.({
550
570
  path,
@@ -28,11 +28,12 @@ export interface DrawerNavigationGroupProps {
28
28
  */
29
29
  drawerOpen: boolean;
30
30
  /**
31
- * Whether tooltips should be shown (drawer closed + hovered)
31
+ * @deprecated No longer read. Entry tooltips are uncontrolled — they follow the
32
+ * pointer and focus, and are suppressed wherever the label is already visible.
32
33
  */
33
- tooltipsOpen: boolean;
34
+ tooltipsOpen?: boolean;
34
35
  /**
35
- * Whether admin menu is open (used to control tooltip visibility)
36
+ * @deprecated No longer read. See {@link tooltipsOpen}.
36
37
  */
37
38
  adminMenuOpen?: boolean;
38
39
  /**
@@ -66,8 +67,6 @@ export function DrawerNavigationGroup({
66
67
  collapsed,
67
68
  onToggleCollapsed,
68
69
  drawerOpen,
69
- tooltipsOpen,
70
- adminMenuOpen,
71
70
  headerActions,
72
71
  onItemClick,
73
72
  hideHeader,
@@ -158,8 +157,6 @@ export function DrawerNavigationGroup({
158
157
  <ResolvedDrawerNavigationItem
159
158
  key={entry.id}
160
159
  icon={<IconForView collectionOrView={entry.collection ?? entry.view} size={"small"}/>}
161
- tooltipsOpen={!collapsed && tooltipsOpen}
162
- adminMenuOpen={adminMenuOpen}
163
160
  drawerOpen={drawerOpen}
164
161
  onClick={() => onItemClick?.(entry)}
165
162
  url={entry.url}
@@ -7,16 +7,16 @@ export function DrawerNavigationItem({
7
7
  name,
8
8
  icon,
9
9
  drawerOpen,
10
- adminMenuOpen,
11
- tooltipsOpen,
12
10
  url,
13
11
  onClick,
14
12
  indented = false
15
13
  }: {
16
14
  icon: React.ReactElement,
17
15
  name: string,
18
- tooltipsOpen: boolean,
16
+ /** @deprecated No longer read: the tooltip is uncontrolled. */
17
+ tooltipsOpen?: boolean,
19
18
  drawerOpen: boolean,
19
+ /** @deprecated No longer read: the tooltip is uncontrolled. */
20
20
  adminMenuOpen?: boolean,
21
21
  url: string,
22
22
  onClick?: () => void,
@@ -40,6 +40,15 @@ export function DrawerNavigationItem({
40
40
  {icon}
41
41
  </div>;
42
42
 
43
+ const [wantsTooltip, setWantsTooltip] = React.useState(false);
44
+
45
+ // Radix will not report a close it never noticed: while the tooltip is masked
46
+ // the pointer can leave the row without `onOpenChange(false)` ever firing. So
47
+ // the stale `true` is dropped on the way *into* the masked state — otherwise
48
+ // it surfaces the moment the mask lifts, as a tooltip hanging over a rail the
49
+ // pointer left long ago.
50
+ if (drawerOpen && wantsTooltip) setWantsTooltip(false);
51
+
43
52
  const listItem = <div>
44
53
  <NavLink
45
54
  onClick={onClick}
@@ -70,8 +79,18 @@ export function DrawerNavigationItem({
70
79
  </NavLink>
71
80
  </div>;
72
81
 
82
+ // Per row, and always controlled. The old shared flag could only say "every
83
+ // tooltip" or "none", and the state it needed was unreachable, so no entry
84
+ // tooltip ever opened — leaving a bare rail of unlabelled icons. Radix decides
85
+ // when this one wants to be open, from the pointer and from focus; the drawer
86
+ // only masks it once the label says the same thing.
87
+ //
88
+ // Masked rather than withheld: dropping `open`, or the title, would move the
89
+ // tooltip between controlled and uncontrolled as the drawer opens, which
90
+ // strands the old state — a tooltip left up over a pointer nowhere near it.
73
91
  return <Tooltip
74
- open={drawerOpen || adminMenuOpen ? false : tooltipsOpen}
92
+ open={!drawerOpen && wantsTooltip}
93
+ onOpenChange={setWantsTooltip}
75
94
  side="right"
76
95
  title={name}>
77
96
  {listItem}