@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
@@ -24,7 +24,8 @@ import { getEntityPreviewKeys } from "../../util/previews";
24
24
  import { IconForView } from "@rebasepro/app";
25
25
  import { getIcon } from "@rebasepro/app";
26
26
  import { getValueInPath } from "@rebasepro/utils";
27
- import { useCollectionSlotKeys, resolveEntitySlots, type CollectionSlotKeys } from "./usePreviewSlots";
27
+ import { useCollectionSlotKeys, useEntitySlots, type CollectionSlotKeys } from "./usePreviewSlots";
28
+ import { SlotValue, TagChips } from "./SlotValue";
28
29
  import { useAdminContext } from "../../hooks/useAdminContext";
29
30
  import { resolveEntityAction } from "../../util/resolutions";
30
31
 
@@ -78,8 +79,30 @@ type ListColumnDef = {
78
79
  isDate?: boolean;
79
80
  align: "left" | "center" | "right";
80
81
  width: string;
82
+ /**
83
+ * What {@link width} costs in pixels, so the fit calculation can budget in
84
+ * the same units the row lays out in. A flat per-column estimate treated a
85
+ * relation (w-64) as costing the same as a date (w-28), which is where a row
86
+ * ran out of width for the title while still claiming everything fit.
87
+ */
88
+ widthPx: number;
81
89
  };
82
90
 
91
+ /**
92
+ * How wide a column of this property wants to be — the Tailwind class the cell
93
+ * gets, and the pixel cost of that class. The two are returned together because
94
+ * they must not drift: the budget is only meaningful if it describes the widths
95
+ * actually rendered. `lg:` variants key off the viewport, not this panel, so a
96
+ * narrow split on a wide screen gets the larger of the two — the cost we budget.
97
+ */
98
+ function getIdealColumnWidth(prop: Property): { width: string, widthPx: number } {
99
+ if (prop.type === "string" && "enum" in prop) return { width: "flex-shrink-0 w-32", widthPx: 128 };
100
+ if (prop.type === "date" || prop.type === "number" || prop.type === "boolean") return { width: "flex-shrink-0 w-28", widthPx: 112 };
101
+ if (prop.type === "reference" || prop.type === "relation") return { width: "flex-shrink-0 w-56 lg:w-64", widthPx: 256 };
102
+ if (prop.type === "string") return { width: "flex-shrink-0 w-40 lg:w-48", widthPx: 192 };
103
+ return { width: "flex-shrink-0 w-40", widthPx: 160 };
104
+ }
105
+
83
106
  /**
84
107
  * Get row padding/spacing classes based on size
85
108
  */
@@ -108,6 +131,30 @@ function getEstimatedRowHeight(size: CollectionSize): number {
108
131
  }
109
132
  }
110
133
 
134
+ // ── Row layout budget ─────────────────────────────────────────────────
135
+ // What the row spends before a single column is placed. These mirror the
136
+ // classes on the row itself (`px-5`, `gap-4`, `w-8` checkbox, `w-10` image);
137
+ // they are the same layout described twice, so a change to one is a change to
138
+ // the other.
139
+
140
+ /** `px-5` on both sides. */
141
+ const ROW_PADDING_WIDTH = 40;
142
+ /** The checkbox cell (`w-8`). */
143
+ const CHECKBOX_WIDTH = 32;
144
+ /** The thumbnail / icon square (`w-10`). */
145
+ const IMAGE_WIDTH = 40;
146
+ /** `gap-4` between every cell of the row. */
147
+ const COLUMN_GAP = 16;
148
+
149
+ /**
150
+ * The title's share of a row, taken before any column bids for width.
151
+ *
152
+ * A title is a name, and a name truncated to "Lider Sr. en…" has stopped being
153
+ * one. Columns are a scanner's convenience; the title is the row's identity, so
154
+ * a narrow list spends its width on the title and simply shows fewer columns.
155
+ */
156
+ const TITLE_COMFORTABLE_WIDTH = 320;
157
+
111
158
  /** Number of extra rows rendered above/below the viewport. */
112
159
  const OVERSCAN_COUNT = 8;
113
160
 
@@ -293,14 +340,21 @@ export function CollectionListViewBinding<M extends Record<string, unknown> = Re
293
340
  const context = useAdminContext();
294
341
 
295
342
  const containerRef = useRef<HTMLDivElement>(null);
296
- const [containerWidth, setContainerWidth] = useState(1200);
343
+ // `undefined` is "not measured yet", and it renders the title alone. The
344
+ // seed used to be a 1200px guess, which is a claim about a list that may be
345
+ // a 300px column of a split — and, because the ref below was never attached
346
+ // to anything, a guess nothing ever corrected.
347
+ const [containerWidth, setContainerWidth] = useState<number | undefined>(undefined);
297
348
 
298
349
  // Track container width for responsive column visibility
299
350
  useEffect(() => {
300
351
  const el = containerRef.current;
301
352
  if (!el) return;
353
+ const measure = (width: number) => setContainerWidth(width > 0 ? width : undefined);
354
+ measure(el.getBoundingClientRect().width);
355
+ if (typeof ResizeObserver === "undefined") return;
302
356
  const ro = new ResizeObserver(([entry]) => {
303
- if (entry) setContainerWidth(entry.contentRect.width);
357
+ if (entry) measure(entry.contentRect.width);
304
358
  });
305
359
  ro.observe(el);
306
360
  return () => ro.disconnect();
@@ -343,14 +397,6 @@ export function CollectionListViewBinding<M extends Record<string, unknown> = Re
343
397
  const columns = useMemo(() => {
344
398
  const cols: ListColumnDef[] = [];
345
399
 
346
- const getIdealColumnWidth = (prop: Property) => {
347
- if (prop.type === "string" && "enum" in prop) return "flex-shrink-0 w-32";
348
- if (prop.type === "date" || prop.type === "number" || prop.type === "boolean") return "flex-shrink-0 w-28";
349
- if (prop.type === "reference" || prop.type === "relation") return "flex-shrink-0 w-56 lg:w-64";
350
- if (prop.type === "string") return "flex-shrink-0 w-40 lg:w-48";
351
- return "flex-shrink-0 w-40";
352
- };
353
-
354
400
  if (resolvedCollection.listProperties && resolvedCollection.listProperties.length > 0) {
355
401
  resolvedCollection.listProperties.forEach((key, index) => {
356
402
  const prop = resolvedCollection.properties[key] as Property;
@@ -362,7 +408,9 @@ export function CollectionListViewBinding<M extends Record<string, unknown> = Re
362
408
  property: prop,
363
409
  isTitle: index === 0,
364
410
  align: prop.type === "number" || prop.type === "date" ? "right" : "left",
365
- width: index === 0 ? "flex-1 min-w-[200px]" : getIdealColumnWidth(prop)
411
+ ...(index === 0
412
+ ? { width: "flex-1 min-w-0", widthPx: TITLE_COMFORTABLE_WIDTH }
413
+ : getIdealColumnWidth(prop))
366
414
  });
367
415
  });
368
416
  return cols;
@@ -378,7 +426,8 @@ export function CollectionListViewBinding<M extends Record<string, unknown> = Re
378
426
  property: prop,
379
427
  isTitle: true,
380
428
  align: "left",
381
- width: "flex-1 min-w-[200px]"
429
+ width: "flex-1 min-w-0",
430
+ widthPx: TITLE_COMFORTABLE_WIDTH
382
431
  });
383
432
  }
384
433
  }
@@ -421,7 +470,7 @@ export function CollectionListViewBinding<M extends Record<string, unknown> = Re
421
470
  label: prop.name || key,
422
471
  property: prop,
423
472
  align: prop.type === "number" || prop.type === "date" ? "right" : "left",
424
- width: getIdealColumnWidth(prop)
473
+ ...getIdealColumnWidth(prop)
425
474
  });
426
475
  });
427
476
 
@@ -434,7 +483,8 @@ export function CollectionListViewBinding<M extends Record<string, unknown> = Re
434
483
  property: prop,
435
484
  isStatus: true,
436
485
  align: "center",
437
- width: "flex-shrink-0 w-32"
486
+ width: "flex-shrink-0 w-32",
487
+ widthPx: 128
438
488
  });
439
489
  }
440
490
  }
@@ -448,7 +498,8 @@ export function CollectionListViewBinding<M extends Record<string, unknown> = Re
448
498
  property: prop,
449
499
  isDate: true,
450
500
  align: "right",
451
- width: "flex-shrink-0 w-28"
501
+ width: "flex-shrink-0 w-28",
502
+ widthPx: 112
452
503
  });
453
504
  }
454
505
  }
@@ -458,33 +509,39 @@ export function CollectionListViewBinding<M extends Record<string, unknown> = Re
458
509
 
459
510
  const showImage = size !== "xs";
460
511
 
461
- // Responsive: determine visible columns based on container width.
462
- // The first extra column requires significantly more available space (600px)
463
- // to ensure the title area has generous room before columns appear.
464
- // Each subsequent column needs 160px.
512
+ // Responsive: keep only the columns the row can actually pay for.
513
+ //
514
+ // The title is what a row is *for* — it is the one cell that identifies the
515
+ // record — so it is not one competitor among the columns: it takes its
516
+ // comfortable share off the top, and the columns divide what is left. Each
517
+ // one is charged its own rendered width, and the first that cannot be
518
+ // afforded ends the row: dropping a column and keeping the narrower one
519
+ // after it would reorder the row as it resizes.
465
520
  const visibleColumns = useMemo(() => {
466
521
  if (columns.length <= 1) return columns;
467
522
 
468
523
  const titleCol = columns.find(c => c.isTitle);
469
524
  const extraCols = columns.filter(c => !c.isTitle);
470
-
471
- // Base width consumed by fixed-width elements (padding + checkbox + image + min title)
472
- const baseWidth = 40 + (selectionEnabled ? 40 : 0) + (showImage ? 56 : 0) + 200;
473
- const availableForExtra = Math.max(0, containerWidth - baseWidth);
474
-
475
- // First extra column needs 500px; each additional needs 160px
476
- const FIRST_COL_THRESHOLD = 500;
477
- const EXTRA_COL_WIDTH = 160;
478
-
479
- let maxExtraCols = 0;
480
- if (availableForExtra >= FIRST_COL_THRESHOLD) {
481
- maxExtraCols = 1 + Math.floor((availableForExtra - FIRST_COL_THRESHOLD) / EXTRA_COL_WIDTH);
525
+ const withTitle = (kept: ListColumnDef[]) => [...(titleCol ? [titleCol] : []), ...kept];
526
+
527
+ // Unmeasured: the title alone, which is the answer we would rather flash
528
+ // than a row of columns squeezing it down to an ellipsis.
529
+ if (containerWidth === undefined) return withTitle([]);
530
+
531
+ const chrome = ROW_PADDING_WIDTH
532
+ + (selectionEnabled ? CHECKBOX_WIDTH + COLUMN_GAP : 0)
533
+ + (showImage ? IMAGE_WIDTH + COLUMN_GAP : 0);
534
+ let remaining = containerWidth - chrome - TITLE_COMFORTABLE_WIDTH;
535
+
536
+ const kept: ListColumnDef[] = [];
537
+ for (const col of extraCols) {
538
+ const cost = col.widthPx + COLUMN_GAP;
539
+ if (cost > remaining) break;
540
+ remaining -= cost;
541
+ kept.push(col);
482
542
  }
483
543
 
484
- return [
485
- ...(titleCol ? [titleCol] : []),
486
- ...extraCols.slice(0, maxExtraCols)
487
- ];
544
+ return withTitle(kept);
488
545
  }, [columns, containerWidth, selectionEnabled, showImage]);
489
546
 
490
547
  const handleEntityClick = useCallback((entity: Entity<M>) => {
@@ -528,54 +585,56 @@ customEntityActions });
528
585
  }, [handleSelectionChange]);
529
586
 
530
587
  return (
531
- <ListView<Entity<M>>
532
- data={data}
533
- dataLoading={dataLoading}
534
- noMoreToLoad={noMoreToLoad}
535
- dataLoadingError={dataLoadingError}
536
- itemCount={itemCount}
537
- setItemCount={setItemCount}
538
- pageSize={pageSize}
539
- paginationEnabled={paginationEnabled}
540
- onItemClick={handleEntityClick}
541
- selectedIds={selectedIds}
542
- highlightedIds={highlightedIds}
543
- selectionEnabled={selectionEnabled}
544
- emptyComponent={emptyComponent}
545
- size={size}
546
- selectedEntityId={selectedEntityId}
547
- renderRow={useCallback(({ item: entity, style, className, selected, highlighted, isLast, onClick, onSelectionChange }) => {
548
- return (
549
- <div
550
- key={entity.id}
551
- style={style}
552
- className={className}
553
- >
554
- <ListRow
555
- entity={entity}
556
- collection={resolvedCollection}
557
- onClick={handleEntityClick}
558
- selected={selected}
559
- highlighted={highlighted}
560
- onSelectionChange={handleRowSelectionChange}
561
- selectionEnabled={selectionEnabled}
562
- columns={visibleColumns}
563
- slotKeys={slotKeys}
564
- rowClasses={rowClasses}
565
- showImage={showImage}
566
- size={size}
567
- isLast={isLast}
568
- isActive={selectedEntityId !== undefined && entity.id === selectedEntityId}
569
- listViewActions={getListViewActions(entity)}
570
- context={context}
571
- path={path}
572
- selectionController={selectionController}
573
- openEntityMode={openEntityMode}
574
- />
575
- </div>
576
- );
577
- }, [resolvedCollection, selectionEnabled, visibleColumns, slotKeys, rowClasses, showImage, size, selectedEntityId, getListViewActions, context, path, selectionController, openEntityMode, handleRowSelectionChange, handleEntityClick])}
578
- />
588
+ <div ref={containerRef} className="w-full">
589
+ <ListView<Entity<M>>
590
+ data={data}
591
+ dataLoading={dataLoading}
592
+ noMoreToLoad={noMoreToLoad}
593
+ dataLoadingError={dataLoadingError}
594
+ itemCount={itemCount}
595
+ setItemCount={setItemCount}
596
+ pageSize={pageSize}
597
+ paginationEnabled={paginationEnabled}
598
+ onItemClick={handleEntityClick}
599
+ selectedIds={selectedIds}
600
+ highlightedIds={highlightedIds}
601
+ selectionEnabled={selectionEnabled}
602
+ emptyComponent={emptyComponent}
603
+ size={size}
604
+ selectedEntityId={selectedEntityId}
605
+ renderRow={useCallback(({ item: entity, style, className, selected, highlighted, isLast, onClick, onSelectionChange }) => {
606
+ return (
607
+ <div
608
+ key={entity.id}
609
+ style={style}
610
+ className={className}
611
+ >
612
+ <ListRow
613
+ entity={entity}
614
+ collection={resolvedCollection}
615
+ onClick={handleEntityClick}
616
+ selected={selected}
617
+ highlighted={highlighted}
618
+ onSelectionChange={handleRowSelectionChange}
619
+ selectionEnabled={selectionEnabled}
620
+ columns={visibleColumns}
621
+ slotKeys={slotKeys}
622
+ rowClasses={rowClasses}
623
+ showImage={showImage}
624
+ size={size}
625
+ isLast={isLast}
626
+ isActive={selectedEntityId !== undefined && entity.id === selectedEntityId}
627
+ listViewActions={getListViewActions(entity)}
628
+ context={context}
629
+ path={path}
630
+ selectionController={selectionController}
631
+ openEntityMode={openEntityMode}
632
+ />
633
+ </div>
634
+ );
635
+ }, [resolvedCollection, selectionEnabled, visibleColumns, slotKeys, rowClasses, showImage, size, selectedEntityId, getListViewActions, context, path, selectionController, openEntityMode, handleRowSelectionChange, handleEntityClick])}
636
+ />
637
+ </div>
579
638
  );
580
639
  }
581
640
 
@@ -628,8 +687,11 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
628
687
  selectionController?: SelectionController<M>;
629
688
  openEntityMode?: "side_panel" | "full_screen" | "split" | "dialog";
630
689
  }) {
631
- // ── Resolve slots (pure function, no hooks) ──
632
- const slots = resolveEntitySlots(
690
+ // ── Resolve slots ──
691
+ // A hook now, not a pure call: a `display` resolver may have to fetch what
692
+ // fills a slot, so the row subscribes to the result and re-renders when it
693
+ // lands. `ListRow` is memoized, which is what keeps that to the one row.
694
+ const slots = useEntitySlots(
633
695
  entity as Entity<Record<string, unknown>>,
634
696
  collection as AdminCollection<Record<string, unknown>>,
635
697
  slotKeys
@@ -696,7 +758,7 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
696
758
  <div className="flex-shrink-0">
697
759
  {slots.image ? (
698
760
  <div className={cls("w-10 h-10 rounded-lg border relative overflow-hidden bg-surface-100 dark:bg-surface-900", defaultBorderMixin)}>
699
- <PropertyPreview propertyKey={slots.image.propertyKey} value={slots.image.value} property={slots.image.property} size="small" fill={true}/>
761
+ <SlotValue slot={slots.image} size="small" fill={true}/>
700
762
  </div>
701
763
  ) : (
702
764
  <div className={cls("w-10 h-10 rounded-lg bg-surface-100 dark:bg-surface-900 flex items-center justify-center border", defaultBorderMixin)}>
@@ -715,13 +777,7 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
715
777
  <div className="truncate">
716
778
  {slots.title?.value !== undefined ? (
717
779
  <Typography component="div" variant="body2" className="font-semibold text-surface-900 dark:text-surface-50 truncate transition-colors group-hover:text-primary-600 dark:group-hover:text-primary-400">
718
- <PropertyPreview
719
- propertyKey={slots.title.propertyKey}
720
- value={slots.title.value}
721
- property={slots.title.property}
722
- size="small"
723
- textOnly={true}
724
- />
780
+ <SlotValue slot={slots.title} size="small"/>
725
781
  </Typography>
726
782
  ) : (
727
783
  <Typography component="div" variant="body2" className="font-semibold text-surface-500 dark:text-surface-400 font-mono text-xs transition-colors group-hover:text-primary-600 dark:group-hover:text-primary-400">
@@ -734,13 +790,7 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
734
790
  {slots.subtitle && (
735
791
  <div className="truncate mt-0.5">
736
792
  <Typography variant="caption" component="div" className="text-surface-500 dark:text-surface-400 truncate">
737
- <PropertyPreview
738
- propertyKey={slots.subtitle.propertyKey}
739
- value={slots.subtitle.value}
740
- property={slots.subtitle.property}
741
- size="small"
742
- textOnly={true}
743
- />
793
+ <SlotValue slot={slots.subtitle} size="small"/>
744
794
  </Typography>
745
795
  </div>
746
796
  )}
@@ -820,23 +870,33 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
820
870
  })}
821
871
  </>
822
872
  ) : (
823
- /* ── SLOT MODE (editorial scanner layout) ── */
873
+ /* ── SLOT MODE (editorial scanner layout) ──
874
+ The slots give up the row from the right inwards, the same
875
+ rule the columns follow above: whatever sits furthest from
876
+ the title is furthest from what the row is about, so it is
877
+ the first thing a narrowing row can afford to lose. Reading
878
+ the thresholds in source order gives the reverse of the
879
+ order they vanish in — they are written left to right, and
880
+ they disappear right to left. */
824
881
  <div className="flex items-center gap-4 flex-shrink-0 ml-auto">
825
- {/* STATUS slot — hidden when row is narrow */}
882
+ {/* TAGS slot — "free chips beside the status", so beside it.
883
+ Leftmost of the three, so the last to go. */}
884
+ {slots.tags && (
885
+ <div className="flex items-center gap-1 flex-shrink-0 @max-[360px]:hidden">
886
+ <TagChips slot={slots.tags} max={3}/>
887
+ </div>
888
+ )}
889
+
890
+ {/* STATUS slot */}
826
891
  {slots.status && (
827
- <div className="flex-shrink-0 @max-[400px]:hidden">
828
- <PropertyPreview
829
- propertyKey={slots.status.propertyKey}
830
- value={slots.status.value}
831
- property={slots.status.property}
832
- size="small"
833
- />
892
+ <div className="flex-shrink-0 @max-[460px]:hidden">
893
+ <SlotValue slot={slots.status} size="small"/>
834
894
  </div>
835
895
  )}
836
896
 
837
- {/* DATE slot */}
897
+ {/* DATE slot — rightmost, so the first to go. */}
838
898
  {slots.date && (
839
- <div className="flex-shrink-0 text-right w-[80px] @max-[500px]:hidden">
899
+ <div className="flex-shrink-0 text-right w-[80px] @max-[560px]:hidden">
840
900
  <Typography variant="caption" className="whitespace-nowrap text-surface-400 dark:text-surface-500 font-medium">
841
901
  {slots.date.formatted ?? "—"}
842
902
  </Typography>
@@ -74,7 +74,7 @@ export function CollectionViewActions<M extends Record<string, unknown>>({
74
74
  size="small"
75
75
  variant="filled"
76
76
  color="primary">
77
- Add {collection.singularName ?? collection.name}
77
+ {t("add_specific", { name: collection.singularName ?? collection.name })}
78
78
  </Button>
79
79
  : <Button
80
80
  id={`add_entity_${path}`}
@@ -28,6 +28,7 @@ import { ReferencePreview } from "../../preview";
28
28
  import {
29
29
  CollectionScopeProvider,
30
30
  OnColumnResizeParams,
31
+ getRedundantChildViewColumnIds,
31
32
  useAnalyticsController,
32
33
  useAuthController,
33
34
  useColumnIds,
@@ -687,7 +688,16 @@ parentEntityIds: parentEntityIds ?? EMPTY_ARRAY,
687
688
  // Each child view gets a column that jumps to its tab. Keyed by the
688
689
  // view's collection slug, which is the relation key — two relations
689
690
  // to the same target used to collapse onto one column id here.
690
- const subcollectionsList = getEntityChildViews(collection).map(v => v.collection as AdminCollection);
691
+ //
692
+ // Except where the relation was declared as a property and so already
693
+ // has a column showing the child rows: the button then repeated that
694
+ // column's heading verbatim. Filtered here as well as in
695
+ // `useColumnIds` so no id is displayed without a delegate to build it,
696
+ // and so the column picker does not offer the one back.
697
+ const redundant = getRedundantChildViewColumnIds(collectionWithLocalOrder);
698
+ const subcollectionsList = getEntityChildViews(collection)
699
+ .map(v => v.collection as AdminCollection)
700
+ .filter(child => !redundant.has(getSubcollectionColumnId(child)));
691
701
  const subcollectionColumns: AdditionalFieldDelegate<M, any>[] = subcollectionsList.map((subcollection: AdminCollection) => {
692
702
  return {
693
703
  key: getSubcollectionColumnId(subcollection),
@@ -720,7 +730,7 @@ parentEntityIds: parentEntityIds ?? EMPTY_ARRAY,
720
730
  ...(collection.additionalFields ?? EMPTY_ARRAY),
721
731
  ...subcollectionColumns
722
732
  ];
723
- }, [collection, path, sidePanelController]);
733
+ }, [collection, collectionWithLocalOrder, path, sidePanelController]);
724
734
 
725
735
  const updateLastDeleteTimestamp = useCallback(() => {
726
736
  setLastDeleteTimestamp(Date.now());
@@ -823,6 +833,7 @@ parentEntityIds: parentEntityIds ?? EMPTY_ARRAY,
823
833
  collection={collection}
824
834
  filter={tableController.filterValues}
825
835
  sortBy={tableController.sortBy}
836
+ searchString={tableController.searchString}
826
837
  onCountChange={setDocsCount}
827
838
  />;
828
839
 
@@ -1126,13 +1137,18 @@ parentEntityIds,
1126
1137
 
1127
1138
  {countFetcher}
1128
1139
 
1129
- {tableController.dataLoadingError && tableController.data.length > 0 && (
1140
+ {/* A plugin's error view takes priority over the built-in
1141
+ banner, the same precedence the empty state uses. This was
1142
+ computed above and then rendered nowhere, so `collection.error`
1143
+ — a declared, documented slot with its own props interface —
1144
+ did nothing at all, and a plugin author had no way to tell. */}
1145
+ {pluginErrorView ?? (tableController.dataLoadingError && tableController.data.length > 0 && (
1130
1146
  <div className="flex items-center gap-4 px-4 py-2 bg-red-50 dark:bg-red-900/20 border-b border-red-200 dark:border-red-800 flex-shrink-0">
1131
1147
  <Typography variant="body2" className="text-red-700 dark:text-red-300 flex-1">
1132
1148
  <strong>Warning:</strong> {tableController.dataLoadingError.message || "Failed to update data."}
1133
1149
  </Typography>
1134
1150
  </div>
1135
- )}
1151
+ ))}
1136
1152
 
1137
1153
  {/* When isSplitLayout, SplitListView is ALWAYS mounted — regardless
1138
1154
  of viewMode. The toolbar + current view live in the left panel;
@@ -1302,17 +1318,26 @@ function DefaultCollectionEmptyState({
1302
1318
  */
1303
1319
  const inflightCountRequests = new Map<string, Promise<number>>();
1304
1320
 
1305
- function EntitiesCount({
1321
+ export function EntitiesCount({
1306
1322
  path,
1307
1323
  collection,
1308
1324
  filter,
1309
1325
  sortBy,
1326
+ searchString,
1310
1327
  onCountChange
1311
1328
  }: {
1312
1329
  path: string,
1313
1330
  collection: AdminCollection,
1314
1331
  filter?: FilterValues<any>,
1315
1332
  sortBy?: [string, "asc" | "desc"],
1333
+ /**
1334
+ * Required, not optional. The term sits in the same scope as the element
1335
+ * that mounts this and is passed to the toolbar and the empty state beside
1336
+ * it — it was simply not passed here, so a searched list showed the count
1337
+ * of the unsearched collection. A required prop is what stops the next
1338
+ * caller doing the same; there is no useful "count without the search".
1339
+ */
1340
+ searchString: string | undefined,
1316
1341
  onCountChange?: (count: number | null | undefined) => void,
1317
1342
  }) {
1318
1343
 
@@ -1344,12 +1369,17 @@ function EntitiesCount({
1344
1369
  const orderByParams: [string, "asc" | "desc"] | undefined = sortByProperty && currentSort ? [String(sortByProperty), currentSort] : undefined;
1345
1370
 
1346
1371
  // Deduplicate inflight count requests (e.g. React StrictMode double-mount)
1347
- const cacheKey = `${path}|${filterKey}|${sortByProperty ?? ""}|${currentSort ?? ""}`;
1372
+ // The search term is part of the query, so it is part of the key. The
1373
+ // cache is module-level and outlives an unmount, so a key that omits it
1374
+ // does not merely lose precision — it answers one search with a
1375
+ // different search's total.
1376
+ const cacheKey = `${path}|${filterKey}|${sortByProperty ?? ""}|${currentSort ?? ""}|${searchString ?? ""}`;
1348
1377
  let countPromise = inflightCountRequests.get(cacheKey);
1349
1378
  if (!countPromise) {
1350
1379
  countPromise = accessor.count({
1351
1380
  where: whereParams,
1352
- orderBy: orderByParams
1381
+ orderBy: orderByParams,
1382
+ searchString
1353
1383
  });
1354
1384
  inflightCountRequests.set(cacheKey, countPromise);
1355
1385
  // Clean up the inflight entry once resolved/rejected
@@ -1364,7 +1394,7 @@ function EntitiesCount({
1364
1394
  });
1365
1395
 
1366
1396
  return () => { cancelled = true; };
1367
- }, [path, filterKey, sortByProperty, currentSort]);
1397
+ }, [path, filterKey, sortByProperty, currentSort, searchString]);
1368
1398
 
1369
1399
  // Count is now displayed in the breadcrumb bar, this component only fetches and reports
1370
1400
  return null;
@@ -16,7 +16,8 @@ import {
16
16
  } from "@rebasepro/app";
17
17
  import { useAnalyticsController } from "@rebasepro/app";
18
18
  import { IconForView } from "@rebasepro/app";
19
- import { useCollectionSlotKeys, resolveEntitySlots } from "./usePreviewSlots";
19
+ import { useCollectionSlotKeys, useEntitySlots } from "./usePreviewSlots";
20
+ import { SlotValue, TagChips } from "./SlotValue";
20
21
 
21
22
  export type EntityCardBindingProps<M extends Record<string, unknown> = Record<string, unknown>> = {
22
23
  entity: Entity<M>;
@@ -56,13 +57,10 @@ export function EntityCardBinding<M extends Record<string, unknown> = Record<str
56
57
  customizationController.propertyConfigs
57
58
  );
58
59
 
59
- const slots = useMemo(
60
- () => resolveEntitySlots(
61
- entity as Entity<Record<string, unknown>>,
62
- collection as AdminCollection<Record<string, unknown>>,
63
- slotKeys
64
- ),
65
- [entity, collection, slotKeys]
60
+ const slots = useEntitySlots(
61
+ entity as Entity<Record<string, unknown>>,
62
+ collection as AdminCollection<Record<string, unknown>>,
63
+ slotKeys
66
64
  );
67
65
 
68
66
  const handleClick = (e?: React.MouseEvent) => {
@@ -104,13 +102,7 @@ export function EntityCardBinding<M extends Record<string, unknown> = Record<str
104
102
  <div className="aspect-[4/3] relative overflow-hidden bg-surface-100 dark:bg-surface-900">
105
103
  {slots.image ? (
106
104
  <div className="w-full h-full">
107
- <PropertyPreview
108
- property={slots.image.property}
109
- propertyKey={slots.image.propertyKey}
110
- size="medium"
111
- value={slots.image.value}
112
- fill={true}
113
- />
105
+ <SlotValue slot={slots.image} size="medium" fill={true}/>
114
106
  </div>
115
107
  ) : (
116
108
  <div className="w-full h-full flex items-center justify-center">
@@ -160,12 +152,7 @@ export function EntityCardBinding<M extends Record<string, unknown> = Record<str
160
152
  {/* Title slot */}
161
153
  <div className="truncate my-1 text-sm font-medium min-h-[20px]">
162
154
  {slots.title ? (
163
- <PropertyPreview
164
- propertyKey={slots.title.propertyKey}
165
- value={slots.title.value}
166
- property={slots.title.property}
167
- size="small"
168
- />
155
+ <SlotValue slot={slots.title} size="small"/>
169
156
  ) : (
170
157
  <Typography variant="body2" className="text-surface-500">
171
158
  {entity.id}
@@ -176,12 +163,7 @@ export function EntityCardBinding<M extends Record<string, unknown> = Record<str
176
163
  {/* Subtitle slot */}
177
164
  {slots.subtitle && (
178
165
  <div className="line-clamp-3 [&_div]:line-clamp-3 text-xs text-surface-600 dark:text-surface-400 [&_p]:!my-1 [&_p:first-child]:!mt-0 [&_p:last-child]:!mb-0">
179
- <PropertyPreview
180
- propertyKey={slots.subtitle.propertyKey}
181
- value={slots.subtitle.value}
182
- property={slots.subtitle.property}
183
- size="small"
184
- />
166
+ <SlotValue slot={slots.subtitle} size="small"/>
185
167
  </div>
186
168
  )}
187
169
 
@@ -208,15 +190,20 @@ export function EntityCardBinding<M extends Record<string, unknown> = Record<str
208
190
  </div>
209
191
  )}
210
192
 
211
- {/* Status slot */}
212
- {slots.status && (
213
- <div className="mt-1">
214
- <PropertyPreview
215
- propertyKey={slots.status.propertyKey}
216
- value={slots.status.value}
217
- property={slots.status.property}
218
- size="small"
219
- />
193
+ {/* Status + tags slot — "free chips beside the status" */}
194
+ {(slots.status || slots.tags) && (
195
+ <div className="flex items-center gap-1 mt-1 flex-wrap">
196
+ {slots.status && <SlotValue slot={slots.status} size="small"/>}
197
+ {slots.tags && <TagChips slot={slots.tags}/>}
198
+ </div>
199
+ )}
200
+
201
+ {/* Date slot — a card is a list row with the image on top, and the
202
+ row has always shown it. It was the one slot the card resolved
203
+ and never rendered. */}
204
+ {slots.date && (
205
+ <div className="mt-1 text-[10px] text-surface-500 dark:text-surface-400">
206
+ {slots.date.formatted}
220
207
  </div>
221
208
  )}
222
209
  </div>