@rebasepro/admin 0.13.0 → 0.13.1-canary.g394d868

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 (137) hide show
  1. package/dist/{CollectionEditorDialog-CjXkWZ64.js → CollectionEditorDialog-D78xsXgL.js} +3 -3
  2. package/dist/{CollectionEditorDialog-CjXkWZ64.js.map → CollectionEditorDialog-D78xsXgL.js.map} +1 -1
  3. package/dist/{PropertyEditView-W8I3YV89.js → PropertyEditView-B88EC-1f.js} +2 -2
  4. package/dist/{PropertyEditView-W8I3YV89.js.map → PropertyEditView-B88EC-1f.js.map} +1 -1
  5. package/dist/{RouterCollectionsStudioView-BsLTNoqX.js → RouterCollectionsStudioView-B1Ws7h9l.js} +4 -4
  6. package/dist/{RouterCollectionsStudioView-BsLTNoqX.js.map → RouterCollectionsStudioView-B1Ws7h9l.js.map} +1 -1
  7. package/dist/collection_editor/serializable_types.d.ts +1 -0
  8. package/dist/collection_editor_ui.js +4 -4
  9. package/dist/components/AdditionalFieldValue.d.ts +25 -0
  10. package/dist/components/CollectionViewBinding/CollectionViewBinding.d.ts +16 -0
  11. package/dist/components/CollectionViewBinding/SlotValue.d.ts +63 -0
  12. package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +17 -4
  13. package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +12 -0
  14. package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +55 -5
  15. package/dist/components/DetailViewBinding.d.ts +15 -0
  16. package/dist/components/DrawerNavigationGroup.d.ts +9 -6
  17. package/dist/components/DrawerNavigationItem.d.ts +16 -6
  18. package/dist/components/EditViewBinding.d.ts +30 -1
  19. package/dist/components/EntityDisplayHeader.d.ts +53 -0
  20. package/dist/components/EntityIdentityBar.d.ts +43 -1
  21. package/dist/components/EntityViewBinding.d.ts +60 -6
  22. package/dist/components/RebaseLayout.d.ts +1 -1
  23. package/dist/components/RebaseShell.d.ts +1 -1
  24. package/dist/components/RelationSelector.d.ts +8 -1
  25. package/dist/components/UserSelector.d.ts +8 -1
  26. package/dist/components/app/Scaffold.d.ts +9 -3
  27. package/dist/components/index.d.ts +2 -0
  28. package/dist/{export-J79rMoNj.js → export-BZhdWhkB.js} +2 -2
  29. package/dist/{export-J79rMoNj.js.map → export-BZhdWhkB.js.map} +1 -1
  30. package/dist/form/components/FieldBlock.d.ts +58 -4
  31. package/dist/form/components/FormRail.d.ts +13 -0
  32. package/dist/form/components/FormSections.d.ts +15 -1
  33. package/dist/form/components/useRailVisible.d.ts +5 -4
  34. package/dist/form/form_utils.d.ts +38 -1
  35. package/dist/{history-D7L_JlLO.js → history-BPpt68CL.js} +4 -4
  36. package/dist/{history-D7L_JlLO.js.map → history-BPpt68CL.js.map} +1 -1
  37. package/dist/hooks/useEntityDisplay.d.ts +102 -0
  38. package/dist/hooks/useEntityDisplayTitle.d.ts +18 -9
  39. package/dist/{import-BvYl--U4.js → import-gRfemegK.js} +3 -3
  40. package/dist/{import-BvYl--U4.js.map → import-gRfemegK.js.map} +1 -1
  41. package/dist/index.d.ts +2 -2
  42. package/dist/index.js +152 -57
  43. package/dist/index.js.map +1 -1
  44. package/dist/preview/compact_text.d.ts +23 -0
  45. package/dist/preview/components/BooleanPreview.d.ts +3 -1
  46. package/dist/preview/property_previews/ArrayOfStringsPreview.d.ts +1 -1
  47. package/dist/preview/property_previews/ArrayOneOfPreview.d.ts +1 -1
  48. package/dist/preview/property_previews/ArrayPropertyPreview.d.ts +1 -1
  49. package/dist/preview/property_previews/MapPropertyPreview.d.ts +1 -1
  50. package/dist/preview/property_previews/StringPropertyPreview.d.ts +1 -1
  51. package/dist/routes/RebaseRoute.d.ts +1 -1
  52. package/dist/types/components/PropertyPreviewProps.d.ts +19 -0
  53. package/dist/util/number_format.d.ts +14 -0
  54. package/dist/util/previews.d.ts +9 -2
  55. package/dist/{util-GiwPgxnL.js → util-BHq3VOjH.js} +9080 -7861
  56. package/dist/util-BHq3VOjH.js.map +1 -0
  57. package/package.json +9 -9
  58. package/src/collection_editor/serializable_types.ts +1 -0
  59. package/src/components/AdditionalFieldValue.tsx +68 -0
  60. package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +7 -8
  61. package/src/components/CollectionTableBinding/PropertyTableCell.tsx +2 -2
  62. package/src/components/CollectionTableBinding/column_utils.tsx +15 -6
  63. package/src/components/CollectionTableBinding/internal/popup_field/PopupFormField.tsx +2 -2
  64. package/src/components/CollectionViewBinding/BoardCardBinding.tsx +26 -27
  65. package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +172 -112
  66. package/src/components/CollectionViewBinding/CollectionViewActions.tsx +1 -1
  67. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +38 -8
  68. package/src/components/CollectionViewBinding/EntityCardBinding.tsx +23 -36
  69. package/src/components/CollectionViewBinding/SlotValue.tsx +156 -0
  70. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +23 -7
  71. package/src/components/CollectionViewBinding/SplitListShowButton.tsx +28 -0
  72. package/src/components/CollectionViewBinding/SplitListView.tsx +33 -2
  73. package/src/components/CollectionViewBinding/useBoardDataController.tsx +12 -3
  74. package/src/components/CollectionViewBinding/usePreviewSlots.ts +177 -54
  75. package/src/components/DefaultDrawer.tsx +19 -23
  76. package/src/components/DetailViewBinding.tsx +87 -55
  77. package/src/components/DrawerNavigationGroup.tsx +16 -39
  78. package/src/components/DrawerNavigationItem.tsx +44 -16
  79. package/src/components/EditViewBinding.tsx +211 -41
  80. package/src/components/EntityDisplayHeader.tsx +170 -0
  81. package/src/components/EntityIdentityBar.tsx +149 -23
  82. package/src/components/EntityPreviewBinding.tsx +58 -18
  83. package/src/components/EntityViewBinding.tsx +342 -41
  84. package/src/components/InlineEntityPreview.tsx +10 -10
  85. package/src/components/RebaseLayout.tsx +2 -2
  86. package/src/components/RebaseShell.tsx +2 -2
  87. package/src/components/ReferenceTable/SelectionTableBinding.tsx +4 -2
  88. package/src/components/RelationSelector.tsx +15 -4
  89. package/src/components/SideDialogs.tsx +15 -3
  90. package/src/components/SidePanelBinding.tsx +122 -66
  91. package/src/components/UserSelector.tsx +15 -4
  92. package/src/components/app/Scaffold.tsx +112 -10
  93. package/src/components/index.ts +3 -0
  94. package/src/form/EntityForm.tsx +64 -52
  95. package/src/form/EntityFormBinding.tsx +24 -11
  96. package/src/form/PropertyFieldBinding.tsx +2 -2
  97. package/src/form/components/FieldBlock.tsx +80 -10
  98. package/src/form/components/FormRail.tsx +19 -2
  99. package/src/form/components/FormSections.tsx +32 -5
  100. package/src/form/components/StorageUploadProgress.tsx +9 -3
  101. package/src/form/components/useRailVisible.tsx +5 -4
  102. package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +2 -2
  103. package/src/form/field_bindings/BinaryFieldBinding.tsx +10 -5
  104. package/src/form/field_bindings/BlockFieldBinding.tsx +3 -3
  105. package/src/form/field_bindings/GeopointFieldBinding.tsx +23 -16
  106. package/src/form/field_bindings/MapFieldBinding.tsx +2 -2
  107. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +13 -7
  108. package/src/form/field_bindings/MultiSelectFieldBinding.tsx +7 -1
  109. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +7 -1
  110. package/src/form/field_bindings/ReferenceFieldBinding.tsx +1 -1
  111. package/src/form/field_bindings/RelationFieldBinding.tsx +5 -2
  112. package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -3
  113. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +2 -2
  114. package/src/form/field_bindings/UserSelectFieldBinding.tsx +4 -1
  115. package/src/form/field_bindings/VectorFieldBinding.tsx +6 -4
  116. package/src/form/form_utils.ts +78 -0
  117. package/src/hooks/navigation/useBuildCollectionRegistryController.tsx +29 -5
  118. package/src/hooks/useEntityDisplay.tsx +262 -0
  119. package/src/hooks/useEntityDisplayTitle.tsx +27 -38
  120. package/src/index.ts +9 -1
  121. package/src/preview/PropertyPreview.tsx +25 -5
  122. package/src/preview/compact_text.ts +68 -0
  123. package/src/preview/components/BooleanPreview.tsx +5 -2
  124. package/src/preview/components/StorageThumbnail.tsx +5 -2
  125. package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +6 -3
  126. package/src/preview/property_previews/ArrayOfStringsPreview.tsx +26 -1
  127. package/src/preview/property_previews/ArrayOneOfPreview.tsx +10 -1
  128. package/src/preview/property_previews/ArrayPropertyPreview.tsx +10 -1
  129. package/src/preview/property_previews/MapPropertyPreview.tsx +52 -1
  130. package/src/preview/property_previews/NumberPropertyPreview.tsx +9 -1
  131. package/src/preview/property_previews/StringPropertyPreview.tsx +8 -5
  132. package/src/routes/RebaseRoute.tsx +62 -4
  133. package/src/types/components/PropertyPreviewProps.tsx +21 -0
  134. package/src/util/number_format.ts +46 -0
  135. package/src/util/previews.ts +8 -55
  136. package/src/util/property_utils.tsx +6 -1
  137. package/dist/util-GiwPgxnL.js.map +0 -1
@@ -1,14 +1,16 @@
1
1
  import { useMemo } from "react";
2
2
  import type { Property, RelationProperty } from "@rebasepro/types";
3
3
  import type { Entity, EntityRelation } from "@rebasepro/types";
4
- import type { PropertyConfig, AdminCollection } from "@rebasepro/admin-types";
4
+ import type { PropertyConfig, AdminCollection, EntityDisplayRole } 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,13 +25,29 @@ 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;
41
+ /**
42
+ * Which display role this slot fills.
43
+ *
44
+ * Load-bearing for the computed case. Without a property there is nothing to
45
+ * infer from, and "render it as text" is right for a title and wrong for an
46
+ * image: a resolver that returned a URL had that URL printed inside the card's
47
+ * image frame. The role is the one thing that distinguishes them, so it
48
+ * travels with the slot rather than being re-derived at each call site.
49
+ */
50
+ role?: EntityDisplayRole;
33
51
  }
34
52
 
35
53
  /**
@@ -89,6 +107,19 @@ export interface EntityPreviewSlots {
89
107
  status: PreviewSlot | undefined;
90
108
  /** META — date / timestamp */
91
109
  date: DatePreviewSlot | undefined;
110
+ /**
111
+ * TAGS — free chips beside the status.
112
+ *
113
+ * Declared only. Every other role has an inference ladder behind it, and
114
+ * this one deliberately does not: there is no "first enum" or "looks like a
115
+ * timestamp" that means *labels*, so guessing would scatter arbitrary chips
116
+ * across every row of every panel that never asked for them.
117
+ *
118
+ * The role existed on `EntityDisplay` and in `useEntityDisplay` before this
119
+ * field did, which is why a declared `display.tags` resolved correctly and
120
+ * then had nowhere to go.
121
+ */
122
+ tags: PreviewSlot | undefined;
92
123
  /** Entity ID (always available) */
93
124
  entityId: string | number;
94
125
  }
@@ -108,6 +139,8 @@ export interface CollectionSlotKeys {
108
139
  relationKeys: string[];
109
140
  statusKey: string | undefined;
110
141
  dateKey: string | undefined;
142
+ /** Declared only — see {@link EntityPreviewSlots.tags}. */
143
+ tagsKey: string | undefined;
111
144
  }
112
145
 
113
146
  /**
@@ -129,10 +162,19 @@ export function resolveCollectionSlotKeys(
129
162
  ? [leadingRelationKey, ...rankedCandidates.filter(key => key !== leadingRelationKey)]
130
163
  : rankedCandidates;
131
164
  const titleKey = titleKeyCandidates[0];
165
+ // `getEntityImagePreviewPropertyKey` prefers `display.image` itself, so
166
+ // every caller of it — this, the reference card, the board — agrees.
132
167
  const imageKey = getEntityImagePreviewPropertyKey(collection);
133
168
 
134
- // Status: first string-enum that isn't the title
135
- let statusKey: string | undefined;
169
+ // Status: declared, else the first string-enum that isn't the title.
170
+ //
171
+ // Everything below this line is inference — "the first enum", "a key that
172
+ // looks like a timestamp", "the longest remaining string". It is a good
173
+ // guess and it is only a guess, so a collection that states the role wins
174
+ // outright and the ladder is skipped. That is the whole point of
175
+ // `admin.display`: the heuristics stay for the collections that never say
176
+ // anything, and stop overruling the ones that do.
177
+ let statusKey: string | undefined = getDisplayPropertyKey(collection, "status");
136
178
 
137
179
  // 1. Explicitly defined in previewProperties
138
180
  if (!statusKey && collection.previewProperties) {
@@ -169,13 +211,15 @@ export function resolveCollectionSlotKeys(
169
211
  }
170
212
  }
171
213
 
172
- // Date: prefer well-known timestamp fields, fallback to any date
173
- let dateKey: string | undefined;
214
+ // Date: declared, else a well-known timestamp field, else any date
215
+ let dateKey: string | undefined = getDisplayPropertyKey(collection, "date");
174
216
  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;
217
+ if (!dateKey) {
218
+ for (const candidate of dateCandidates) {
219
+ if (collection.properties[candidate]) {
220
+ dateKey = candidate;
221
+ break;
222
+ }
179
223
  }
180
224
  }
181
225
  if (!dateKey) {
@@ -222,7 +266,8 @@ export function resolveCollectionSlotKeys(
222
266
  const scoreB = propB?.type === "string" ? (propB.admin?.multiline ? 2 : 1) : 0;
223
267
  return scoreB - scoreA;
224
268
  });
225
- const subtitleKey = sortedPreviewKeys.length > 0 ? sortedPreviewKeys[0] : undefined;
269
+ const subtitleKey = getDisplayPropertyKey(collection, "subtitle")
270
+ ?? (sortedPreviewKeys.length > 0 ? sortedPreviewKeys[0] : undefined);
226
271
 
227
272
  return { titleKey,
228
273
  titleKeyCandidates,
@@ -230,7 +275,8 @@ imageKey,
230
275
  subtitleKey,
231
276
  relationKeys,
232
277
  statusKey,
233
- dateKey };
278
+ dateKey,
279
+ tagsKey: getDisplayPropertyKey(collection, "tags") };
234
280
  }
235
281
 
236
282
  // ── Image unwrapping helper ───────────────────────────────────────────
@@ -261,6 +307,7 @@ function resolveImageSlot(
261
307
  if (resolvedValue === undefined || resolvedValue === null) return undefined;
262
308
 
263
309
  return {
310
+ role: "image",
264
311
  property: resolvedProperty as Property,
265
312
  propertyKey: imageKey,
266
313
  value: resolvedValue
@@ -348,7 +395,7 @@ export function resolveEntitySlots(
348
395
  collection: AdminCollection<Record<string, unknown>>,
349
396
  slotKeys: CollectionSlotKeys
350
397
  ): EntityPreviewSlots {
351
- const { titleKey, titleKeyCandidates, imageKey, subtitleKey, relationKeys, statusKey, dateKey } = slotKeys;
398
+ const { titleKey, titleKeyCandidates, imageKey, subtitleKey, relationKeys, statusKey, dateKey, tagsKey } = slotKeys;
352
399
 
353
400
  // Image
354
401
  const image = imageKey ? resolveImageSlot(collection, imageKey, entity) : undefined;
@@ -361,7 +408,8 @@ export function resolveEntitySlots(
361
408
  if (resolvedTitleKey) {
362
409
  const prop = collection.properties[resolvedTitleKey] as Property | undefined;
363
410
  const val = getValueInPath(entity.values, resolvedTitleKey);
364
- if (prop) title = { property: prop,
411
+ if (prop) title = { role: "title",
412
+ property: prop,
365
413
  propertyKey: resolvedTitleKey,
366
414
  value: val };
367
415
  }
@@ -372,7 +420,8 @@ value: val };
372
420
  const prop = collection.properties[subtitleKey] as Property | undefined;
373
421
  const val = getValueInPath(entity.values, subtitleKey);
374
422
  if (prop && val !== undefined && val !== null && val !== "") {
375
- subtitle = { property: prop,
423
+ subtitle = { role: "subtitle",
424
+ property: prop,
376
425
  propertyKey: subtitleKey,
377
426
  value: val };
378
427
  }
@@ -448,7 +497,8 @@ id: relation.id });
448
497
  const prop = collection.properties[statusKey] as Property | undefined;
449
498
  const val = getValueInPath(entity.values, statusKey);
450
499
  if (prop && val !== undefined && val !== null) {
451
- status = { property: prop,
500
+ status = { role: "status",
501
+ property: prop,
452
502
  propertyKey: statusKey,
453
503
  value: val };
454
504
  }
@@ -461,6 +511,7 @@ value: val };
461
511
  const val = getValueInPath(entity.values, dateKey);
462
512
  if (prop && val !== undefined && val !== null) {
463
513
  date = {
514
+ role: "date",
464
515
  property: prop,
465
516
  propertyKey: dateKey,
466
517
  value: val,
@@ -469,6 +520,23 @@ value: val };
469
520
  }
470
521
  }
471
522
 
523
+ // Tags — declared only, and empty means absent: an empty array is "this
524
+ // record has no labels", which must render as nothing rather than as an
525
+ // empty chip row that shifts every other slot down.
526
+ let tags: PreviewSlot | undefined;
527
+ if (tagsKey) {
528
+ const prop = collection.properties[tagsKey] as Property | undefined;
529
+ const val = getValueInPath(entity.values, tagsKey);
530
+ const empty = val === undefined || val === null || val === ""
531
+ || (Array.isArray(val) && val.length === 0);
532
+ if (prop && !empty) {
533
+ tags = { role: "tags",
534
+ property: prop,
535
+ propertyKey: tagsKey,
536
+ value: val };
537
+ }
538
+ }
539
+
472
540
  return {
473
541
  image,
474
542
  title,
@@ -476,6 +544,7 @@ value: val };
476
544
  relations,
477
545
  status,
478
546
  date,
547
+ tags,
479
548
  entityId: entity.id
480
549
  };
481
550
  }
@@ -506,43 +575,26 @@ function resolveRelationDisplayName(
506
575
  }
507
576
 
508
577
  // Helper: extract display name from entity values using the target collection
578
+ //
579
+ // This used to carry a ranking of its own — priority keys `name/title/label/
580
+ // displayName`, then the first visible non-id string — which is a *seventh*
581
+ // answer to "what is this record called", and one that disagreed with the
582
+ // shared one: it read `titleProperty` with a flat lookup (so a dotted path
583
+ // was ignored), never saw `display.title`, and ranked by key name where the
584
+ // shared ranking excludes identifiers structurally. A chip and the record it
585
+ // points at could therefore be labelled differently on the same screen.
586
+ //
587
+ // What is left here is the part that is genuinely local: a relation carries
588
+ // *eagerly loaded* values, not an entity, so a resolver cannot run against
589
+ // it — a computed title falls back to the ranked property, which is the same
590
+ // thing the list row shows while its own resolver is in flight.
509
591
  const extractDisplayName = (values: Record<string, unknown>): string | undefined => {
510
592
  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
- }
593
+ for (const key of getTitlePropertyCandidates(targetCollection)) {
594
+ const value = getValueInPath(values, key);
595
+ if (value === undefined || value === null || value === "") continue;
596
+ if (typeof value === "string" && looksLikeIdentifierValue(value)) continue;
597
+ return String(value);
546
598
  }
547
599
  }
548
600
  // Generic fallback: walk entity values for any short string.
@@ -582,3 +634,74 @@ function resolveRelationDisplayName(
582
634
  // 3. Final fallback: show the ID
583
635
  return id !== undefined ? String(id) : undefined;
584
636
  }
637
+
638
+ /**
639
+ * The slots for one record, with `admin.display` resolvers applied.
640
+ *
641
+ * {@link resolveEntitySlots} answers the question from values already in hand —
642
+ * which is everything a slot filled by a property needs, and nothing a slot
643
+ * filled by a *resolver* can use. A computed title may have to be fetched, so it
644
+ * arrives after the first render; this hook is where the two meet.
645
+ *
646
+ * Until a resolver lands, the derived slot shows. That is deliberate: a list of
647
+ * fifty rows must not blank its titles for a beat while fifty promises settle,
648
+ * and the derived answer — the best-ranked property carrying a readable value —
649
+ * is a reasonable thing to be looking at meanwhile. When the resolved value
650
+ * arrives it replaces the slot outright, property and all, because there is no
651
+ * property behind a computed value (see {@link PreviewSlot}).
652
+ *
653
+ * Every role is hooked unconditionally, so the hook order is fixed regardless of
654
+ * what a given collection declares.
655
+ */
656
+ export function useEntitySlots(
657
+ entity: Entity<Record<string, unknown>>,
658
+ collection: AdminCollection<Record<string, unknown>>,
659
+ slotKeys: CollectionSlotKeys
660
+ ): EntityPreviewSlots {
661
+
662
+ const derived = useMemo(
663
+ () => resolveEntitySlots(entity, collection, slotKeys),
664
+ [entity, collection, slotKeys]
665
+ );
666
+
667
+ const params = { collection,
668
+ entity };
669
+ const title = useEntityDisplay<string>("title", params);
670
+ const subtitle = useEntityDisplay<string>("subtitle", params);
671
+ const image = useEntityDisplay<string>("image", params);
672
+ const status = useEntityDisplay<string>("status", params);
673
+ const date = useEntityDisplay<Date | string | number>("date", params);
674
+ const tags = useEntityDisplay<string[] | string>("tags", params);
675
+
676
+ return useMemo(() => {
677
+ // `source` is set only when the value came from a property, so its
678
+ // absence is exactly the "this was computed" signal — no second flag to
679
+ // keep in step with the first.
680
+ // The role travels with the computed slot too. It is the only thing left
681
+ // to say about a value with no property behind it, and `SlotValue` needs
682
+ // it to tell a computed image from a computed title.
683
+ const computed = (
684
+ role: EntityDisplayRole,
685
+ resolved: { value: unknown, source?: unknown }
686
+ ): PreviewSlot | undefined =>
687
+ resolved.value !== undefined && resolved.source === undefined
688
+ ? { role,
689
+ value: resolved.value }
690
+ : undefined;
691
+
692
+ const computedDate = computed("date", date);
693
+
694
+ return {
695
+ ...derived,
696
+ title: computed("title", title) ?? derived.title,
697
+ subtitle: computed("subtitle", subtitle) ?? derived.subtitle,
698
+ image: computed("image", image) ?? derived.image,
699
+ status: computed("status", status) ?? derived.status,
700
+ date: computedDate
701
+ ? { ...computedDate,
702
+ formatted: formatDateValue(computedDate.value) }
703
+ : derived.date,
704
+ tags: computed("tags", tags) ?? derived.tags
705
+ };
706
+ }, [derived, title, subtitle, image, status, date, tags]);
707
+ }
@@ -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
  );