@rebasepro/admin 0.13.0 → 0.13.1-canary.g06dbe5b

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 (188) hide show
  1. package/dist/{CollectionEditorDialog-CjXkWZ64.js → CollectionEditorDialog-C3DbFNqp.js} +3 -3
  2. package/dist/{CollectionEditorDialog-CjXkWZ64.js.map → CollectionEditorDialog-C3DbFNqp.js.map} +1 -1
  3. package/dist/{PropertyEditView-W8I3YV89.js → PropertyEditView-DFH8rXRN.js} +5 -5
  4. package/dist/PropertyEditView-DFH8rXRN.js.map +1 -0
  5. package/dist/{RouterCollectionsStudioView-BsLTNoqX.js → RouterCollectionsStudioView-DiUDOu4b.js} +9 -13
  6. package/dist/RouterCollectionsStudioView-DiUDOu4b.js.map +1 -0
  7. package/dist/collection_editor/serializable_types.d.ts +1 -0
  8. package/dist/collection_editor_ui.js +4 -4
  9. package/dist/components/AdditionalFieldValue.d.ts +25 -0
  10. package/dist/components/ClearFilterSortButton.d.ts +7 -1
  11. package/dist/components/CollectionViewBinding/CollectionViewBinding.d.ts +16 -0
  12. package/dist/components/CollectionViewBinding/EntityCardBinding.d.ts +3 -1
  13. package/dist/components/CollectionViewBinding/SearchExplanation.d.ts +48 -0
  14. package/dist/components/CollectionViewBinding/SearchHighlight.d.ts +101 -0
  15. package/dist/components/CollectionViewBinding/SlotValue.d.ts +63 -0
  16. package/dist/components/CollectionViewBinding/SortButton.d.ts +7 -1
  17. package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +17 -4
  18. package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +12 -0
  19. package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +55 -5
  20. package/dist/components/DetailViewBinding.d.ts +15 -0
  21. package/dist/components/DrawerNavigationGroup.d.ts +9 -6
  22. package/dist/components/DrawerNavigationItem.d.ts +16 -6
  23. package/dist/components/EditViewBinding.d.ts +30 -1
  24. package/dist/components/EntityDisplayHeader.d.ts +53 -0
  25. package/dist/components/EntityIdentityBar.d.ts +43 -1
  26. package/dist/components/EntityViewBinding.d.ts +60 -6
  27. package/dist/components/RebaseLayout.d.ts +1 -1
  28. package/dist/components/RebaseShell.d.ts +1 -1
  29. package/dist/components/RelationSelector.d.ts +8 -1
  30. package/dist/components/UserSelector.d.ts +8 -1
  31. package/dist/components/app/Scaffold.d.ts +9 -3
  32. package/dist/components/index.d.ts +2 -0
  33. package/dist/data_export/export/export.d.ts +7 -0
  34. package/dist/data_export/export/fetch_export_data.d.ts +48 -0
  35. package/dist/data_export/export/index.d.ts +1 -0
  36. package/dist/data_import/utils/csv.d.ts +30 -0
  37. package/dist/data_import/utils/index.d.ts +2 -0
  38. package/dist/data_import/utils/save_entities.d.ts +38 -0
  39. package/dist/data_import/utils/transforms.d.ts +9 -0
  40. package/dist/{export-J79rMoNj.js → export-Bd5i6Uvm.js} +175 -39
  41. package/dist/export-Bd5i6Uvm.js.map +1 -0
  42. package/dist/form/components/FieldBlock.d.ts +58 -4
  43. package/dist/form/components/FormRail.d.ts +13 -0
  44. package/dist/form/components/FormSections.d.ts +15 -1
  45. package/dist/form/components/useRailVisible.d.ts +5 -4
  46. package/dist/form/form_utils.d.ts +38 -1
  47. package/dist/{history-D7L_JlLO.js → history-D9R6LUrH.js} +4 -4
  48. package/dist/{history-D7L_JlLO.js.map → history-D9R6LUrH.js.map} +1 -1
  49. package/dist/hooks/useEntityDisplay.d.ts +102 -0
  50. package/dist/hooks/useEntityDisplayTitle.d.ts +18 -9
  51. package/dist/{import-BvYl--U4.js → import-pt_j-0G3.js} +6 -5
  52. package/dist/import-pt_j-0G3.js.map +1 -0
  53. package/dist/index.d.ts +2 -2
  54. package/dist/index.js +158 -60
  55. package/dist/index.js.map +1 -1
  56. package/dist/preview/compact_text.d.ts +23 -0
  57. package/dist/preview/components/BooleanPreview.d.ts +3 -1
  58. package/dist/preview/property_previews/ArrayOfStringsPreview.d.ts +1 -1
  59. package/dist/preview/property_previews/ArrayOneOfPreview.d.ts +1 -1
  60. package/dist/preview/property_previews/ArrayPropertyPreview.d.ts +1 -1
  61. package/dist/preview/property_previews/MapPropertyPreview.d.ts +1 -1
  62. package/dist/preview/property_previews/StringPropertyPreview.d.ts +1 -1
  63. package/dist/routes/RebaseRoute.d.ts +1 -1
  64. package/dist/types/components/PropertyPreviewProps.d.ts +19 -0
  65. package/dist/util/number_format.d.ts +14 -0
  66. package/dist/util/previews.d.ts +9 -2
  67. package/dist/util/view_constants.d.ts +24 -0
  68. package/dist/util/view_mode.d.ts +18 -0
  69. package/dist/{util-GiwPgxnL.js → util-nX_-JRuF.js} +3984 -1774
  70. package/dist/util-nX_-JRuF.js.map +1 -0
  71. package/package.json +10 -9
  72. package/src/collection_editor/serializable_types.ts +1 -0
  73. package/src/collection_editor/ui/collection_editor/CollectionsStudioView.tsx +9 -11
  74. package/src/collection_editor/ui/collection_editor/properties/MapPropertyField.tsx +1 -1
  75. package/src/collection_editor/ui/collection_editor/properties/advanced/AdvancedPropertyValidation.tsx +5 -2
  76. package/src/collection_editor/useLocalCollectionsConfigController.tsx +10 -3
  77. package/src/components/AdditionalFieldValue.tsx +68 -0
  78. package/src/components/ClearFilterSortButton.tsx +16 -7
  79. package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +7 -8
  80. package/src/components/CollectionTableBinding/PropertyTableCell.tsx +2 -2
  81. package/src/components/CollectionTableBinding/column_utils.tsx +15 -6
  82. package/src/components/CollectionTableBinding/internal/popup_field/PopupFormField.tsx +2 -2
  83. package/src/components/CollectionTableBinding/table_bindings.tsx +7 -1
  84. package/src/components/CollectionViewBinding/BoardCardBinding.tsx +26 -27
  85. package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +13 -7
  86. package/src/components/CollectionViewBinding/CollectionCardViewBinding.tsx +2 -1
  87. package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +822 -354
  88. package/src/components/CollectionViewBinding/CollectionViewActions.tsx +1 -1
  89. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +51 -12
  90. package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +9 -0
  91. package/src/components/CollectionViewBinding/EntityCardBinding.tsx +51 -39
  92. package/src/components/CollectionViewBinding/SearchExplanation.tsx +106 -0
  93. package/src/components/CollectionViewBinding/SearchHighlight.tsx +311 -0
  94. package/src/components/CollectionViewBinding/SlotValue.tsx +156 -0
  95. package/src/components/CollectionViewBinding/SortButton.tsx +16 -4
  96. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +23 -7
  97. package/src/components/CollectionViewBinding/SplitListShowButton.tsx +28 -0
  98. package/src/components/CollectionViewBinding/SplitListView.tsx +47 -7
  99. package/src/components/CollectionViewBinding/ViewModeToggle.tsx +10 -5
  100. package/src/components/CollectionViewBinding/useBoardDataController.tsx +23 -6
  101. package/src/components/CollectionViewBinding/usePreviewSlots.ts +183 -64
  102. package/src/components/CollectionViewBinding/utils.ts +7 -4
  103. package/src/components/DefaultDrawer.tsx +19 -23
  104. package/src/components/DetailViewBinding.tsx +109 -62
  105. package/src/components/DrawerNavigationGroup.tsx +16 -39
  106. package/src/components/DrawerNavigationItem.tsx +44 -16
  107. package/src/components/EditViewBinding.tsx +233 -50
  108. package/src/components/EntityDisplayHeader.tsx +170 -0
  109. package/src/components/EntityIdentityBar.tsx +163 -23
  110. package/src/components/EntityInspector.tsx +24 -6
  111. package/src/components/EntityPreviewBinding.tsx +58 -18
  112. package/src/components/EntityViewBinding.tsx +344 -41
  113. package/src/components/InlineEntityPreview.tsx +10 -10
  114. package/src/components/RebaseLayout.tsx +2 -2
  115. package/src/components/RebaseShell.tsx +2 -2
  116. package/src/components/ReferenceTable/SelectionTableBinding.tsx +4 -2
  117. package/src/components/RelationSelector.tsx +35 -10
  118. package/src/components/SideDialogs.tsx +15 -3
  119. package/src/components/SidePanelBinding.tsx +122 -66
  120. package/src/components/UserSelector.tsx +67 -13
  121. package/src/components/app/Scaffold.tsx +112 -10
  122. package/src/components/history/LastEditedByIndicator.tsx +7 -12
  123. package/src/components/index.ts +3 -0
  124. package/src/data_export/export/ExportCollectionAction.tsx +101 -48
  125. package/src/data_export/export/export.ts +63 -16
  126. package/src/data_export/export/fetch_export_data.ts +90 -0
  127. package/src/data_export/export/index.ts +1 -0
  128. package/src/data_import/components/ImportSaveInProgress.tsx +35 -36
  129. package/src/data_import/import/ImportCollectionAction.tsx +13 -2
  130. package/src/data_import/utils/csv.ts +153 -0
  131. package/src/data_import/utils/data.ts +16 -2
  132. package/src/data_import/utils/file_to_json.ts +51 -5
  133. package/src/data_import/utils/index.ts +2 -0
  134. package/src/data_import/utils/save_entities.ts +138 -0
  135. package/src/data_import/utils/transforms.ts +23 -0
  136. package/src/form/EntityForm.tsx +74 -53
  137. package/src/form/EntityFormBinding.tsx +24 -11
  138. package/src/form/PropertyFieldBinding.tsx +2 -2
  139. package/src/form/components/FieldBlock.tsx +80 -10
  140. package/src/form/components/FormRail.tsx +19 -2
  141. package/src/form/components/FormSections.tsx +32 -5
  142. package/src/form/components/StorageUploadProgress.tsx +9 -3
  143. package/src/form/components/useRailVisible.tsx +5 -4
  144. package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +2 -2
  145. package/src/form/field_bindings/BinaryFieldBinding.tsx +10 -5
  146. package/src/form/field_bindings/BlockFieldBinding.tsx +3 -3
  147. package/src/form/field_bindings/GeopointFieldBinding.tsx +23 -16
  148. package/src/form/field_bindings/MapFieldBinding.tsx +2 -2
  149. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +13 -7
  150. package/src/form/field_bindings/MultiSelectFieldBinding.tsx +7 -1
  151. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +21 -2
  152. package/src/form/field_bindings/ReferenceFieldBinding.tsx +1 -1
  153. package/src/form/field_bindings/RelationFieldBinding.tsx +5 -2
  154. package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -3
  155. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +2 -2
  156. package/src/form/field_bindings/UserSelectFieldBinding.tsx +4 -1
  157. package/src/form/field_bindings/VectorFieldBinding.tsx +6 -4
  158. package/src/form/form_utils.ts +78 -0
  159. package/src/hooks/navigation/useBuildCollectionRegistryController.tsx +29 -5
  160. package/src/hooks/useEntityDisplay.tsx +262 -0
  161. package/src/hooks/useEntityDisplayTitle.tsx +27 -38
  162. package/src/index.ts +9 -1
  163. package/src/preview/PropertyPreview.tsx +25 -5
  164. package/src/preview/compact_text.ts +68 -0
  165. package/src/preview/components/BooleanPreview.tsx +5 -2
  166. package/src/preview/components/ReferencePreview.tsx +5 -8
  167. package/src/preview/components/RelationPreview.tsx +8 -7
  168. package/src/preview/components/StorageThumbnail.tsx +5 -2
  169. package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +6 -3
  170. package/src/preview/property_previews/ArrayOfStringsPreview.tsx +26 -1
  171. package/src/preview/property_previews/ArrayOneOfPreview.tsx +10 -1
  172. package/src/preview/property_previews/ArrayPropertyPreview.tsx +10 -1
  173. package/src/preview/property_previews/MapPropertyPreview.tsx +52 -1
  174. package/src/preview/property_previews/NumberPropertyPreview.tsx +9 -1
  175. package/src/preview/property_previews/StringPropertyPreview.tsx +8 -5
  176. package/src/routes/RebaseRoute.tsx +62 -4
  177. package/src/types/components/PropertyPreviewProps.tsx +21 -0
  178. package/src/util/navigation_utils.ts +4 -2
  179. package/src/util/number_format.ts +46 -0
  180. package/src/util/previews.ts +8 -55
  181. package/src/util/property_utils.tsx +6 -1
  182. package/src/util/view_constants.ts +27 -0
  183. package/src/util/view_mode.ts +36 -0
  184. package/dist/PropertyEditView-W8I3YV89.js.map +0 -1
  185. package/dist/RouterCollectionsStudioView-BsLTNoqX.js.map +0 -1
  186. package/dist/export-J79rMoNj.js.map +0 -1
  187. package/dist/import-BvYl--U4.js.map +0 -1
  188. package/dist/util-GiwPgxnL.js.map +0 -1
@@ -79,6 +79,21 @@ function isListShortcutTarget(target: HTMLElement | null, detailPanel: HTMLEleme
79
79
  return !detailPanel?.contains(target);
80
80
  }
81
81
 
82
+ /**
83
+ * The list's share of the split, as a percentage, before anyone drags it.
84
+ *
85
+ * The list only has to identify a record — a title, a subtitle and a status
86
+ * chip — while the detail beside it carries the whole record and its rail. At
87
+ * 30% the list was taking width from the side that had four columns of fields
88
+ * to lay out; 26% still was, by about the width of one field.
89
+ *
90
+ * A default, not a constraint: {@link savePanelSize} remembers the drag per
91
+ * collection, so anyone who has already sized this list keeps their size. The
92
+ * drag floor is `minPanelSizePx`, and it holds below ~1100px of split — at that
93
+ * width this percentage is already the floor.
94
+ */
95
+ const DEFAULT_PANEL_SIZE = 22;
96
+
82
97
  function getSavedPanelSize(path: string): number {
83
98
  try {
84
99
  const saved = localStorage.getItem(`${PANEL_SIZE_KEY}_${path}`);
@@ -87,7 +102,7 @@ function getSavedPanelSize(path: string): number {
87
102
  if (!isNaN(val) && val > 0 && val < 100) return val;
88
103
  }
89
104
  } catch { /* ignore */ }
90
- return 30;
105
+ return DEFAULT_PANEL_SIZE;
91
106
  }
92
107
 
93
108
  function savePanelSize(path: string, size: number) {
@@ -150,8 +165,20 @@ export function SplitListView<M extends Record<string, unknown> = Record<string,
150
165
  dirty.current = modified;
151
166
  }, []);
152
167
 
153
- // The form remounts per entity, so its dirty state must not leak into the next one.
168
+ // The form remounts per entity, so its dirty state must not leak into the
169
+ // next one — and it has to be cleared here rather than left to the next
170
+ // form's own first report, because that form spends the load in a skeleton
171
+ // and a navigation during it would prompt about the record before it.
172
+ //
173
+ // On a *change* of record only, never on this view's own mount: a record
174
+ // arriving from full screen carries its edit back in and opens dirty, and
175
+ // the form says so from its first effect — which runs before this one, being
176
+ // a child's. Clearing unconditionally overwrote that report with `false`,
177
+ // and the way out of the split stopped asking about a live edit.
178
+ const previousEntityId = useRef(selectedEntityId);
154
179
  useEffect(() => {
180
+ if (previousEntityId.current === selectedEntityId) return;
181
+ previousEntityId.current = selectedEntityId;
155
182
  dirty.current = false;
156
183
  }, [selectedEntityId]);
157
184
 
@@ -170,12 +197,19 @@ export function SplitListView<M extends Record<string, unknown> = Record<string,
170
197
  return true;
171
198
  }, []));
172
199
 
173
- // Panel size state with persistence
200
+ // Panel size state with persistence.
201
+ //
202
+ // Written on drag, not on mount. An effect that saved on mount stored
203
+ // {@link DEFAULT_PANEL_SIZE} under this collection's key the first time
204
+ // anyone opened the split — after which the default was frozen in every
205
+ // browser that had ever seen it, and changing it here reached nobody but a
206
+ // new visitor. Only a size someone chose is a size worth remembering.
174
207
  const [panelSize, setPanelSize] = useState(() => getSavedPanelSize(path));
175
208
 
176
- useEffect(() => {
177
- savePanelSize(path, panelSize);
178
- }, [panelSize, path]);
209
+ const handlePanelSizeChange = useCallback((size: number) => {
210
+ setPanelSize(size);
211
+ savePanelSize(path, size);
212
+ }, [path]);
179
213
 
180
214
  // ── Animation state ──
181
215
  // We track the "rendered" entity to keep the detail panel mounted during the exit animation.
@@ -366,6 +400,7 @@ export function SplitListView<M extends Record<string, unknown> = Record<string,
366
400
  parentEntityIds={usedParentEntityIds}
367
401
  selectedTab={selectedTab}
368
402
  layout="split"
403
+ onCloseRequest={handleCloseDetail}
369
404
  onEditClick={() => {
370
405
  navigate(buildUrl(`${path}/${renderedEntityId}/edit`));
371
406
  }}
@@ -387,6 +422,11 @@ export function SplitListView<M extends Record<string, unknown> = Record<string,
387
422
  parentEntityIds={usedParentEntityIds}
388
423
  selectedTab={selectedTab}
389
424
  layout="split"
425
+ // The ✕, and where "save and close" lands. A plain
426
+ // navigation: `useNavigationBlocker` above already
427
+ // stands between it and an unsaved edit, so the ✕ asks
428
+ // exactly what Escape and the browser's Back ask.
429
+ onCloseRequest={handleCloseDetail}
390
430
  onValuesModified={setDirty}
391
431
  onSaved={() => {
392
432
  setDirty(false);
@@ -431,7 +471,7 @@ export function SplitListView<M extends Record<string, unknown> = Record<string,
431
471
  secondPanel={detailPanel}
432
472
  showSecondPanel={isDetailVisible}
433
473
  panelSizePercent={animationPhase === "entering" ? 100 : panelSize}
434
- onPanelSizeChange={setPanelSize}
474
+ onPanelSizeChange={handlePanelSizeChange}
435
475
  minPanelSizePx={240}
436
476
  />
437
477
 
@@ -79,12 +79,17 @@ export function ViewModeToggle({
79
79
  const { t } = useTranslation();
80
80
 
81
81
 
82
- // Get icon for current view mode
82
+ // Get icon for current view mode.
83
+ //
84
+ // `small` on the trigger, matching the filters button beside it — the two
85
+ // labelled buttons open the toolbar together, and at 16 against its 20 this
86
+ // one read as the smaller of the pair. The options inside the popover stay
87
+ // at `smallest`, which is the size every other menu icon uses.
83
88
  const getViewModeIcon = () => {
84
- if (viewMode === "kanban") return <KanbanIcon size={iconSize.smallest}/>;
85
- if (viewMode === "cards") return <LayoutGridIcon size={iconSize.smallest}/>;
86
- if (viewMode === "table") return <TableIcon size={iconSize.smallest}/>;
87
- return <ListIcon size={iconSize.smallest}/>;
89
+ if (viewMode === "kanban") return <KanbanIcon size={iconSize.small}/>;
90
+ if (viewMode === "cards") return <LayoutGridIcon size={iconSize.small}/>;
91
+ if (viewMode === "table") return <TableIcon size={iconSize.small}/>;
92
+ return <ListIcon size={iconSize.small}/>;
88
93
  };
89
94
 
90
95
  const getViewModeName = () => {
@@ -1,5 +1,5 @@
1
1
 
2
- import { Entity, FilterValues } from "@rebasepro/types";
2
+ import { Entity, FilterValues, MAX_LIST_LIMIT } from "@rebasepro/types";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { getRelationIncludeParams, useData, useRebaseContext } from "@rebasepro/app";
5
5
  import type { AdminCollection } from "@rebasepro/admin-types";
@@ -220,6 +220,14 @@ export function useBoardDataController<M extends Record<string, unknown> = any,
220
220
  // Skip if we're in the middle of cleanup
221
221
  if (isCleaningUpRef.current) return;
222
222
 
223
+ // `itemCount` grows by one page per "load more" click with nothing
224
+ // stopping it, and a read above `MAX_LIST_LIMIT` is refused rather than
225
+ // trimmed — a column would stop loading with an error instead of
226
+ // stopping at the ceiling. Ask for what the platform serves; `hasMore`
227
+ // below still compares against the count the board asked for, so the
228
+ // column reports itself exhausted exactly where it did before.
229
+ const requestedCount = Math.min(itemCount, MAX_LIST_LIMIT);
230
+
223
231
  const currentDataClient = dataClientRef.current;
224
232
  const currentCollection = collectionRef.current;
225
233
  const currentContext = contextRef.current;
@@ -406,9 +414,10 @@ values: { ...e.values,
406
414
 
407
415
  const fetchOnce = () => accessor.find({
408
416
  where: whereFilter,
409
- limit: itemCount,
417
+ limit: requestedCount,
410
418
  orderBy: orderByParam,
411
- include: includeParams
419
+ include: includeParams,
420
+ searchString: currentSearchString
412
421
  });
413
422
 
414
423
  const onError = (error: Error) => {
@@ -444,9 +453,14 @@ values: { ...e.values,
444
453
  let liveDataReceived = false;
445
454
  const unsubscribe = accessor.listen({
446
455
  where: whereFilter,
447
- limit: itemCount,
456
+ limit: requestedCount,
448
457
  orderBy: orderByParam,
449
- include: includeParams
458
+ include: includeParams,
459
+ // Read into a local at the top of this function and then used
460
+ // nowhere, in both places it was read. The board took a search
461
+ // term, re-subscribed every column when it changed, and
462
+ // returned the same rows.
463
+ searchString: currentSearchString
450
464
  }, res => {
451
465
  liveDataReceived = true;
452
466
  onUpdate(res.data as Entity<M>[]);
@@ -539,7 +553,10 @@ values: { ...e.values,
539
553
  };
540
554
 
541
555
  accessor.count({
542
- where: whereFilter
556
+ where: whereFilter,
557
+ // Or the column header counts the unsearched collection
558
+ // while the column beneath it shows searched rows.
559
+ searchString: currentSearchString
543
560
  }).then(count => {
544
561
  if (isCleaningUpRef.current) return;
545
562
  setColumnData(prev => ({
@@ -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
- import { getValueInPath } from "@rebasepro/utils";
10
+ import { formatRelativeTime, 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
@@ -299,15 +346,11 @@ function formatDateValue(value: unknown): string {
299
346
  if (isNaN(date.getTime())) return String(value);
300
347
 
301
348
  const now = new Date();
302
- const diffMs = now.getTime() - date.getTime();
303
- const diffMins = Math.floor(diffMs / 60000);
304
- const diffHours = Math.floor(diffMs / 3600000);
305
- const diffDays = Math.floor(diffMs / 86400000);
306
-
307
- if (diffMins < 1) return "Just now";
308
- if (diffMins < 60) return `${diffMins}m ago`;
309
- if (diffHours < 24) return `${diffHours}h ago`;
310
- if (diffDays < 7) return `${diffDays}d ago`;
349
+ // A date slot holds whatever property the collection points it at, which is
350
+ // as often a due or publish date as a `updated_at`. `formatRelativeTime`
351
+ // reads the sign, so a scheduled date says "in 3d" instead of "Just now".
352
+ const relative = formatRelativeTime(date, { now });
353
+ if (relative) return relative;
311
354
 
312
355
  return date.toLocaleDateString(undefined, {
313
356
  month: "short",
@@ -348,7 +391,7 @@ export function resolveEntitySlots(
348
391
  collection: AdminCollection<Record<string, unknown>>,
349
392
  slotKeys: CollectionSlotKeys
350
393
  ): EntityPreviewSlots {
351
- const { titleKey, titleKeyCandidates, imageKey, subtitleKey, relationKeys, statusKey, dateKey } = slotKeys;
394
+ const { titleKey, titleKeyCandidates, imageKey, subtitleKey, relationKeys, statusKey, dateKey, tagsKey } = slotKeys;
352
395
 
353
396
  // Image
354
397
  const image = imageKey ? resolveImageSlot(collection, imageKey, entity) : undefined;
@@ -361,7 +404,8 @@ export function resolveEntitySlots(
361
404
  if (resolvedTitleKey) {
362
405
  const prop = collection.properties[resolvedTitleKey] as Property | undefined;
363
406
  const val = getValueInPath(entity.values, resolvedTitleKey);
364
- if (prop) title = { property: prop,
407
+ if (prop) title = { role: "title",
408
+ property: prop,
365
409
  propertyKey: resolvedTitleKey,
366
410
  value: val };
367
411
  }
@@ -372,7 +416,8 @@ value: val };
372
416
  const prop = collection.properties[subtitleKey] as Property | undefined;
373
417
  const val = getValueInPath(entity.values, subtitleKey);
374
418
  if (prop && val !== undefined && val !== null && val !== "") {
375
- subtitle = { property: prop,
419
+ subtitle = { role: "subtitle",
420
+ property: prop,
376
421
  propertyKey: subtitleKey,
377
422
  value: val };
378
423
  }
@@ -448,7 +493,8 @@ id: relation.id });
448
493
  const prop = collection.properties[statusKey] as Property | undefined;
449
494
  const val = getValueInPath(entity.values, statusKey);
450
495
  if (prop && val !== undefined && val !== null) {
451
- status = { property: prop,
496
+ status = { role: "status",
497
+ property: prop,
452
498
  propertyKey: statusKey,
453
499
  value: val };
454
500
  }
@@ -461,6 +507,7 @@ value: val };
461
507
  const val = getValueInPath(entity.values, dateKey);
462
508
  if (prop && val !== undefined && val !== null) {
463
509
  date = {
510
+ role: "date",
464
511
  property: prop,
465
512
  propertyKey: dateKey,
466
513
  value: val,
@@ -469,6 +516,23 @@ value: val };
469
516
  }
470
517
  }
471
518
 
519
+ // Tags — declared only, and empty means absent: an empty array is "this
520
+ // record has no labels", which must render as nothing rather than as an
521
+ // empty chip row that shifts every other slot down.
522
+ let tags: PreviewSlot | undefined;
523
+ if (tagsKey) {
524
+ const prop = collection.properties[tagsKey] as Property | undefined;
525
+ const val = getValueInPath(entity.values, tagsKey);
526
+ const empty = val === undefined || val === null || val === ""
527
+ || (Array.isArray(val) && val.length === 0);
528
+ if (prop && !empty) {
529
+ tags = { role: "tags",
530
+ property: prop,
531
+ propertyKey: tagsKey,
532
+ value: val };
533
+ }
534
+ }
535
+
472
536
  return {
473
537
  image,
474
538
  title,
@@ -476,6 +540,7 @@ value: val };
476
540
  relations,
477
541
  status,
478
542
  date,
543
+ tags,
479
544
  entityId: entity.id
480
545
  };
481
546
  }
@@ -506,43 +571,26 @@ function resolveRelationDisplayName(
506
571
  }
507
572
 
508
573
  // Helper: extract display name from entity values using the target collection
574
+ //
575
+ // This used to carry a ranking of its own — priority keys `name/title/label/
576
+ // displayName`, then the first visible non-id string — which is a *seventh*
577
+ // answer to "what is this record called", and one that disagreed with the
578
+ // shared one: it read `titleProperty` with a flat lookup (so a dotted path
579
+ // was ignored), never saw `display.title`, and ranked by key name where the
580
+ // shared ranking excludes identifiers structurally. A chip and the record it
581
+ // points at could therefore be labelled differently on the same screen.
582
+ //
583
+ // What is left here is the part that is genuinely local: a relation carries
584
+ // *eagerly loaded* values, not an entity, so a resolver cannot run against
585
+ // it — a computed title falls back to the ranked property, which is the same
586
+ // thing the list row shows while its own resolver is in flight.
509
587
  const extractDisplayName = (values: Record<string, unknown>): string | undefined => {
510
588
  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
- }
589
+ for (const key of getTitlePropertyCandidates(targetCollection)) {
590
+ const value = getValueInPath(values, key);
591
+ if (value === undefined || value === null || value === "") continue;
592
+ if (typeof value === "string" && looksLikeIdentifierValue(value)) continue;
593
+ return String(value);
546
594
  }
547
595
  }
548
596
  // Generic fallback: walk entity values for any short string.
@@ -582,3 +630,74 @@ function resolveRelationDisplayName(
582
630
  // 3. Final fallback: show the ID
583
631
  return id !== undefined ? String(id) : undefined;
584
632
  }
633
+
634
+ /**
635
+ * The slots for one record, with `admin.display` resolvers applied.
636
+ *
637
+ * {@link resolveEntitySlots} answers the question from values already in hand —
638
+ * which is everything a slot filled by a property needs, and nothing a slot
639
+ * filled by a *resolver* can use. A computed title may have to be fetched, so it
640
+ * arrives after the first render; this hook is where the two meet.
641
+ *
642
+ * Until a resolver lands, the derived slot shows. That is deliberate: a list of
643
+ * fifty rows must not blank its titles for a beat while fifty promises settle,
644
+ * and the derived answer — the best-ranked property carrying a readable value —
645
+ * is a reasonable thing to be looking at meanwhile. When the resolved value
646
+ * arrives it replaces the slot outright, property and all, because there is no
647
+ * property behind a computed value (see {@link PreviewSlot}).
648
+ *
649
+ * Every role is hooked unconditionally, so the hook order is fixed regardless of
650
+ * what a given collection declares.
651
+ */
652
+ export function useEntitySlots(
653
+ entity: Entity<Record<string, unknown>>,
654
+ collection: AdminCollection<Record<string, unknown>>,
655
+ slotKeys: CollectionSlotKeys
656
+ ): EntityPreviewSlots {
657
+
658
+ const derived = useMemo(
659
+ () => resolveEntitySlots(entity, collection, slotKeys),
660
+ [entity, collection, slotKeys]
661
+ );
662
+
663
+ const params = { collection,
664
+ entity };
665
+ const title = useEntityDisplay<string>("title", params);
666
+ const subtitle = useEntityDisplay<string>("subtitle", params);
667
+ const image = useEntityDisplay<string>("image", params);
668
+ const status = useEntityDisplay<string>("status", params);
669
+ const date = useEntityDisplay<Date | string | number>("date", params);
670
+ const tags = useEntityDisplay<string[] | string>("tags", params);
671
+
672
+ return useMemo(() => {
673
+ // `source` is set only when the value came from a property, so its
674
+ // absence is exactly the "this was computed" signal — no second flag to
675
+ // keep in step with the first.
676
+ // The role travels with the computed slot too. It is the only thing left
677
+ // to say about a value with no property behind it, and `SlotValue` needs
678
+ // it to tell a computed image from a computed title.
679
+ const computed = (
680
+ role: EntityDisplayRole,
681
+ resolved: { value: unknown, source?: unknown }
682
+ ): PreviewSlot | undefined =>
683
+ resolved.value !== undefined && resolved.source === undefined
684
+ ? { role,
685
+ value: resolved.value }
686
+ : undefined;
687
+
688
+ const computedDate = computed("date", date);
689
+
690
+ return {
691
+ ...derived,
692
+ title: computed("title", title) ?? derived.title,
693
+ subtitle: computed("subtitle", subtitle) ?? derived.subtitle,
694
+ image: computed("image", image) ?? derived.image,
695
+ status: computed("status", status) ?? derived.status,
696
+ date: computedDate
697
+ ? { ...computedDate,
698
+ formatted: formatDateValue(computedDate.value) }
699
+ : derived.date,
700
+ tags: computed("tags", tags) ?? derived.tags
701
+ };
702
+ }, [derived, title, subtitle, image, status, date, tags]);
703
+ }
@@ -1,3 +1,5 @@
1
+ import { isArrayValue, readStoredJson, writeStoredJson } from "@rebasepro/utils";
2
+
1
3
  export function addRecentId(collectionId: string, id: string | number) {
2
4
  const recentIds = getRecentIds(collectionId);
3
5
  const newRecentIds = [id, ...recentIds.filter(i => i !== id)];
@@ -9,11 +11,12 @@ export function addRecentId(collectionId: string, id: string | number) {
9
11
  }
10
12
 
11
13
  export function saveSearchedIdsLocally(collectionId: string, ids: (string | number)[]) {
12
- localStorage.setItem("recent_id_searches::" + collectionId, JSON.stringify(ids));
14
+ writeStoredJson("recent_id_searches::" + collectionId, ids);
13
15
  }
14
16
 
15
17
  export function getRecentIds(collectionId: string): (string | number)[] {
16
- const stored = localStorage.getItem("recent_id_searches::" + collectionId);
17
- if (!stored) return [];
18
- return JSON.parse(stored);
18
+ return readStoredJson<(string | number)[]>(
19
+ "recent_id_searches::" + collectionId,
20
+ { fallback: [], accept: isArrayValue }
21
+ );
19
22
  }