@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
@@ -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,
@@ -281,8 +282,15 @@ const CollectionViewBindingInner = React.memo(
281
282
  useEffect(() => {
282
283
  if (!urlView && viewMode !== defaultViewMode) {
283
284
  // View came from saved config but URL doesn't have it - update URL without push
284
- setSearchParams((prev) => {
285
- const next = new URLSearchParams(prev);
285
+ // Built from `window.location.search`, not React Router's
286
+ // `prev`. The table controller writes the filter, sort and
287
+ // search params with `history.replaceState`, which React Router
288
+ // never observes — so `prev` is a stale snapshot, and merging
289
+ // into it silently dropped whatever it had not seen. Switching
290
+ // view mode after typing a search wiped `search=` from the URL,
291
+ // and the next reload came back unfiltered.
292
+ setSearchParams(() => {
293
+ const next = new URLSearchParams(window.location.search);
286
294
  next.set(VIEW_MODE_PARAM, viewMode);
287
295
  return next;
288
296
  }, { replace: true });
@@ -294,8 +302,10 @@ const CollectionViewBindingInner = React.memo(
294
302
  setViewModeState(newMode);
295
303
 
296
304
  // Update URL with __view param via React Router
297
- setSearchParams((prev) => {
298
- const next = new URLSearchParams(prev);
305
+ setSearchParams(() => {
306
+ // See the note above: the live URL is the only writer both
307
+ // this and the table controller agree on.
308
+ const next = new URLSearchParams(window.location.search);
299
309
  if (newMode === defaultViewMode) {
300
310
  next.delete(VIEW_MODE_PARAM);
301
311
  } else {
@@ -687,7 +697,16 @@ parentEntityIds: parentEntityIds ?? EMPTY_ARRAY,
687
697
  // Each child view gets a column that jumps to its tab. Keyed by the
688
698
  // view's collection slug, which is the relation key — two relations
689
699
  // to the same target used to collapse onto one column id here.
690
- const subcollectionsList = getEntityChildViews(collection).map(v => v.collection as AdminCollection);
700
+ //
701
+ // Except where the relation was declared as a property and so already
702
+ // has a column showing the child rows: the button then repeated that
703
+ // column's heading verbatim. Filtered here as well as in
704
+ // `useColumnIds` so no id is displayed without a delegate to build it,
705
+ // and so the column picker does not offer the one back.
706
+ const redundant = getRedundantChildViewColumnIds(collectionWithLocalOrder);
707
+ const subcollectionsList = getEntityChildViews(collection)
708
+ .map(v => v.collection as AdminCollection)
709
+ .filter(child => !redundant.has(getSubcollectionColumnId(child)));
691
710
  const subcollectionColumns: AdditionalFieldDelegate<M, any>[] = subcollectionsList.map((subcollection: AdminCollection) => {
692
711
  return {
693
712
  key: getSubcollectionColumnId(subcollection),
@@ -720,7 +739,7 @@ parentEntityIds: parentEntityIds ?? EMPTY_ARRAY,
720
739
  ...(collection.additionalFields ?? EMPTY_ARRAY),
721
740
  ...subcollectionColumns
722
741
  ];
723
- }, [collection, path, sidePanelController]);
742
+ }, [collection, collectionWithLocalOrder, path, sidePanelController]);
724
743
 
725
744
  const updateLastDeleteTimestamp = useCallback(() => {
726
745
  setLastDeleteTimestamp(Date.now());
@@ -823,6 +842,7 @@ parentEntityIds: parentEntityIds ?? EMPTY_ARRAY,
823
842
  collection={collection}
824
843
  filter={tableController.filterValues}
825
844
  sortBy={tableController.sortBy}
845
+ searchString={tableController.searchString}
826
846
  onCountChange={setDocsCount}
827
847
  />;
828
848
 
@@ -1126,13 +1146,18 @@ parentEntityIds,
1126
1146
 
1127
1147
  {countFetcher}
1128
1148
 
1129
- {tableController.dataLoadingError && tableController.data.length > 0 && (
1149
+ {/* A plugin's error view takes priority over the built-in
1150
+ banner, the same precedence the empty state uses. This was
1151
+ computed above and then rendered nowhere, so `collection.error`
1152
+ — a declared, documented slot with its own props interface —
1153
+ did nothing at all, and a plugin author had no way to tell. */}
1154
+ {pluginErrorView ?? (tableController.dataLoadingError && tableController.data.length > 0 && (
1130
1155
  <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
1156
  <Typography variant="body2" className="text-red-700 dark:text-red-300 flex-1">
1132
1157
  <strong>Warning:</strong> {tableController.dataLoadingError.message || "Failed to update data."}
1133
1158
  </Typography>
1134
1159
  </div>
1135
- )}
1160
+ ))}
1136
1161
 
1137
1162
  {/* When isSplitLayout, SplitListView is ALWAYS mounted — regardless
1138
1163
  of viewMode. The toolbar + current view live in the left panel;
@@ -1302,17 +1327,26 @@ function DefaultCollectionEmptyState({
1302
1327
  */
1303
1328
  const inflightCountRequests = new Map<string, Promise<number>>();
1304
1329
 
1305
- function EntitiesCount({
1330
+ export function EntitiesCount({
1306
1331
  path,
1307
1332
  collection,
1308
1333
  filter,
1309
1334
  sortBy,
1335
+ searchString,
1310
1336
  onCountChange
1311
1337
  }: {
1312
1338
  path: string,
1313
1339
  collection: AdminCollection,
1314
1340
  filter?: FilterValues<any>,
1315
1341
  sortBy?: [string, "asc" | "desc"],
1342
+ /**
1343
+ * Required, not optional. The term sits in the same scope as the element
1344
+ * that mounts this and is passed to the toolbar and the empty state beside
1345
+ * it — it was simply not passed here, so a searched list showed the count
1346
+ * of the unsearched collection. A required prop is what stops the next
1347
+ * caller doing the same; there is no useful "count without the search".
1348
+ */
1349
+ searchString: string | undefined,
1316
1350
  onCountChange?: (count: number | null | undefined) => void,
1317
1351
  }) {
1318
1352
 
@@ -1344,12 +1378,17 @@ function EntitiesCount({
1344
1378
  const orderByParams: [string, "asc" | "desc"] | undefined = sortByProperty && currentSort ? [String(sortByProperty), currentSort] : undefined;
1345
1379
 
1346
1380
  // Deduplicate inflight count requests (e.g. React StrictMode double-mount)
1347
- const cacheKey = `${path}|${filterKey}|${sortByProperty ?? ""}|${currentSort ?? ""}`;
1381
+ // The search term is part of the query, so it is part of the key. The
1382
+ // cache is module-level and outlives an unmount, so a key that omits it
1383
+ // does not merely lose precision — it answers one search with a
1384
+ // different search's total.
1385
+ const cacheKey = `${path}|${filterKey}|${sortByProperty ?? ""}|${currentSort ?? ""}|${searchString ?? ""}`;
1348
1386
  let countPromise = inflightCountRequests.get(cacheKey);
1349
1387
  if (!countPromise) {
1350
1388
  countPromise = accessor.count({
1351
1389
  where: whereParams,
1352
- orderBy: orderByParams
1390
+ orderBy: orderByParams,
1391
+ searchString
1353
1392
  });
1354
1393
  inflightCountRequests.set(cacheKey, countPromise);
1355
1394
  // Clean up the inflight entry once resolved/rejected
@@ -1364,7 +1403,7 @@ function EntitiesCount({
1364
1403
  });
1365
1404
 
1366
1405
  return () => { cancelled = true; };
1367
- }, [path, filterKey, sortByProperty, currentSort]);
1406
+ }, [path, filterKey, sortByProperty, currentSort, searchString]);
1368
1407
 
1369
1408
  // Count is now displayed in the breadcrumb bar, this component only fetches and reports
1370
1409
  return null;
@@ -104,6 +104,13 @@ parentEntityIds,
104
104
  </Tooltip>
105
105
  );
106
106
 
107
+ // Whether the toolbar is showing icon-only controls. The filters button
108
+ // drops its label — and its icon a size with it — on a narrow layout as
109
+ // well as in the split view, and the sort and clear controls beside it
110
+ // follow: keyed on `compact` alone they stayed 20px next to a 16px filter
111
+ // icon at every width below `largeLayout`.
112
+ const iconOnlyToolbar = !largeLayout || Boolean(compact);
113
+
107
114
  const filtersButton = resolvedProperties && tableController.setFilterValues && (
108
115
  <Tooltip title={t("filters")}
109
116
  key={"filters_tooltip"}>
@@ -147,6 +154,7 @@ parentEntityIds,
147
154
  key={"sort_button"}
148
155
  tableController={tableController}
149
156
  properties={resolvedProperties}
157
+ compact={iconOnlyToolbar}
150
158
  />
151
159
  );
152
160
 
@@ -170,6 +178,7 @@ parentEntityIds,
170
178
  <ClearFilterSortButton
171
179
  key={"clear_filter"}
172
180
  tableController={tableController}
181
+ compact={iconOnlyToolbar}
173
182
  enabled={!collection.fixedFilter}/>,
174
183
  filterPresetsButton
175
184
  ];
@@ -16,9 +16,14 @@ 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 { Highlighted } from "./SearchHighlight";
21
+ import { useSearchExplanation, MatchExplanation, fieldLabel } from "./SearchExplanation";
22
+ import { SlotValue, TagChips } from "./SlotValue";
20
23
 
21
24
  export type EntityCardBindingProps<M extends Record<string, unknown> = Record<string, unknown>> = {
25
+ /** The active search, so a card can mark the hit and say where it was. */
26
+ searchString?: string;
22
27
  entity: Entity<M>;
23
28
  collection: AdminCollection<M>;
24
29
  onClick?: (entity: Entity<M>) => void;
@@ -44,7 +49,8 @@ export function EntityCardBinding<M extends Record<string, unknown> = Record<str
44
49
  highlighted,
45
50
  onSelectionChange,
46
51
  selectionEnabled,
47
- size = "m"
52
+ size = "m",
53
+ searchString
48
54
  }: EntityCardBindingProps<M>) {
49
55
  const authController = useAuthController();
50
56
  const analyticsController = useAnalyticsController();
@@ -56,13 +62,19 @@ export function EntityCardBinding<M extends Record<string, unknown> = Record<str
56
62
  customizationController.propertyConfigs
57
63
  );
58
64
 
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]
65
+ const slots = useEntitySlots(
66
+ entity as Entity<Record<string, unknown>>,
67
+ collection as AdminCollection<Record<string, unknown>>,
68
+ slotKeys
69
+ );
70
+
71
+ // Same explanation the list shows, so a reader switching view modes does
72
+ // not lose the reason a card is in front of them.
73
+ const { terms, offSlot } = useSearchExplanation(
74
+ entity,
75
+ collection.properties as Record<string, unknown>,
76
+ [slotKeys.titleKey, slotKeys.subtitleKey],
77
+ searchString
66
78
  );
67
79
 
68
80
  const handleClick = (e?: React.MouseEvent) => {
@@ -104,13 +116,7 @@ export function EntityCardBinding<M extends Record<string, unknown> = Record<str
104
116
  <div className="aspect-[4/3] relative overflow-hidden bg-surface-100 dark:bg-surface-900">
105
117
  {slots.image ? (
106
118
  <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
- />
119
+ <SlotValue slot={slots.image} size="medium" fill={true}/>
114
120
  </div>
115
121
  ) : (
116
122
  <div className="w-full h-full flex items-center justify-center">
@@ -160,12 +166,9 @@ export function EntityCardBinding<M extends Record<string, unknown> = Record<str
160
166
  {/* Title slot */}
161
167
  <div className="truncate my-1 text-sm font-medium min-h-[20px]">
162
168
  {slots.title ? (
163
- <PropertyPreview
164
- propertyKey={slots.title.propertyKey}
165
- value={slots.title.value}
166
- property={slots.title.property}
167
- size="small"
168
- />
169
+ terms.length > 0 && typeof slots.title.value === "string"
170
+ ? <Highlighted text={slots.title.value} terms={terms}/>
171
+ : <SlotValue slot={slots.title} size="small"/>
169
172
  ) : (
170
173
  <Typography variant="body2" className="text-surface-500">
171
174
  {entity.id}
@@ -173,15 +176,19 @@ export function EntityCardBinding<M extends Record<string, unknown> = Record<str
173
176
  )}
174
177
  </div>
175
178
 
176
- {/* Subtitle slot */}
177
- {slots.subtitle && (
179
+ {/* Subtitle slot — or, while searching, where the hit was. A
180
+ card whose match is in a field it does not show looks as
181
+ arbitrary as a list row does. */}
182
+ {offSlot ? (
183
+ <MatchExplanation
184
+ match={offSlot}
185
+ label={fieldLabel(collection.properties as Record<string, unknown>, offSlot.field)}
186
+ />
187
+ ) : slots.subtitle && (
178
188
  <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
- />
189
+ {terms.length > 0 && typeof slots.subtitle.value === "string"
190
+ ? <Highlighted text={slots.subtitle.value} terms={terms}/>
191
+ : <SlotValue slot={slots.subtitle} size="small"/>}
185
192
  </div>
186
193
  )}
187
194
 
@@ -208,15 +215,20 @@ export function EntityCardBinding<M extends Record<string, unknown> = Record<str
208
215
  </div>
209
216
  )}
210
217
 
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
- />
218
+ {/* Status + tags slot — "free chips beside the status" */}
219
+ {(slots.status || slots.tags) && (
220
+ <div className="flex items-center gap-1 mt-1 flex-wrap">
221
+ {slots.status && <SlotValue slot={slots.status} size="small"/>}
222
+ {slots.tags && <TagChips slot={slots.tags}/>}
223
+ </div>
224
+ )}
225
+
226
+ {/* Date slot — a card is a list row with the image on top, and the
227
+ row has always shown it. It was the one slot the card resolved
228
+ and never rendered. */}
229
+ {slots.date && (
230
+ <div className="mt-1 text-[10px] text-surface-500 dark:text-surface-400">
231
+ {slots.date.formatted}
220
232
  </div>
221
233
  )}
222
234
  </div>
@@ -0,0 +1,106 @@
1
+ import React, { useMemo } from "react";
2
+ import type { SearchMatch } from "@rebasepro/types";
3
+ import { Snippet, searchTerms, offSlotMatch, localRowMatch } from "./SearchHighlight";
4
+
5
+ /**
6
+ * The one place a view asks "why is this row here?" and gets something to draw.
7
+ *
8
+ * List, cards and board all render the same title-and-subtitle slots over the
9
+ * same machinery. An explanation that shows up in one of them and not the others
10
+ * reads as randomness rather than as a feature, so the decision and the markup
11
+ * live here and each view calls them.
12
+ */
13
+
14
+ /**
15
+ * Terms to mark inside values the row already shows, plus the single match
16
+ * worth putting where the subtitle would go — or nothing, when the row is not
17
+ * the product of a search or the hit is already visible.
18
+ *
19
+ * Prefers the server's `_matches`, which knows the stemming the index used, and
20
+ * falls back to reading the row directly. That fallback is not belt-and-braces:
21
+ * rows reach the panel over REST, the realtime socket and the offline cache,
22
+ * and only one of those carries query metadata.
23
+ */
24
+ export function useSearchExplanation(
25
+ entity: { values?: Record<string, unknown>; searchMatches?: SearchMatch[] },
26
+ properties: Record<string, unknown> | undefined,
27
+ shownKeys: (string | undefined)[],
28
+ searchString: string | undefined
29
+ ): { terms: string[]; offSlot: SearchMatch | undefined } {
30
+ const terms = useMemo(() => searchTerms(searchString), [searchString]);
31
+
32
+ // Joined so the memo compares by value: the caller builds this array inline,
33
+ // so its identity changes every render and would defeat the memo entirely.
34
+ //
35
+ // `|` is the separator because a property path is identifiers and dots — it
36
+ // cannot contain one, so the round trip is lossless. (A NUL would also be
37
+ // unambiguous and is what this first used; a raw control byte in a source
38
+ // file makes grep treat the whole file as binary, and the repo rejects it.)
39
+ const shownKey = shownKeys.map(k => k ?? "").join("|");
40
+
41
+ const offSlot = useMemo(
42
+ () => {
43
+ if (terms.length === 0) return undefined;
44
+ const shown = shownKey.split("|").map(k => k || undefined);
45
+ return offSlotMatch(entity.searchMatches, shown)
46
+ ?? localRowMatch(entity.values, properties, terms, shown);
47
+ },
48
+ [entity.searchMatches, entity.values, properties, shownKey, terms]
49
+ );
50
+
51
+ return { terms, offSlot };
52
+ }
53
+
54
+ /**
55
+ * Which field the hit was in, and the text around it.
56
+ *
57
+ * Sits where the subtitle would have gone. Deliberately quiet — a small amber
58
+ * caption and one line — because it is context for a result, not the result. It
59
+ * has to survive a narrow column, so it truncates rather than wraps.
60
+ */
61
+ export function MatchExplanation({ match, label }: { match: SearchMatch; label: string }) {
62
+ return (
63
+ <div className="mt-0.5 flex items-baseline gap-1.5 min-w-0">
64
+ <span
65
+ className="shrink-0 text-[10px] uppercase tracking-wide font-semibold text-amber-700 dark:text-amber-500/90"
66
+ title={match.field}
67
+ >
68
+ {label}
69
+ </span>
70
+ <span className="truncate text-xs text-surface-500 dark:text-surface-400 min-w-0">
71
+ <Snippet text={match.snippet}/>
72
+ </span>
73
+ </div>
74
+ );
75
+ }
76
+
77
+ /**
78
+ * What to call a matched field on screen.
79
+ *
80
+ * The path is what the config and the server speak — `questionnaire.
81
+ * certifications` — and is unreadable in a list. Resolve it to the property's
82
+ * declared `name`, walking into `map` properties for a dotted path, and fall
83
+ * back to the last segment humanised, so an unnamed field still reads as words
84
+ * rather than as a path.
85
+ */
86
+ export function fieldLabel(properties: Record<string, unknown> | undefined, path: string): string {
87
+ const segments = path.split(".");
88
+ let node = properties;
89
+ let named: string | undefined;
90
+
91
+ for (const segment of segments) {
92
+ const property = node?.[segment] as { name?: string; properties?: Record<string, unknown> } | undefined;
93
+ // A path can run deeper than the schema describes — `content.value`,
94
+ // where `content` is an array of blocks whose shape is not declared
95
+ // property-by-property. Keep the last name that *was* declared rather
96
+ // than discarding it: "Content" is the answer a reader wants, and
97
+ // resetting to undefined here labelled that field "Value".
98
+ if (!property) break;
99
+ named = property.name ?? named;
100
+ node = property.properties;
101
+ }
102
+
103
+ if (named) return named;
104
+ const last = segments[segments.length - 1] ?? path;
105
+ return last.replace(/[_-]+/g, " ").replace(/^./, c => c.toUpperCase());
106
+ }