@rebasepro/admin 0.13.1-canary.gef9608c → 0.14.0

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 (148) hide show
  1. package/dist/{CollectionEditorDialog-7b-kSLno.js → CollectionEditorDialog-CSheud_E.js} +19 -9
  2. package/dist/CollectionEditorDialog-CSheud_E.js.map +1 -0
  3. package/dist/{PropertyEditView-C-31uvpG.js → PropertyEditView-CWZlC1gq.js} +5 -5
  4. package/dist/PropertyEditView-CWZlC1gq.js.map +1 -0
  5. package/dist/{RouterCollectionsStudioView-BJylABq3.js → RouterCollectionsStudioView-CwTpdzWF.js} +9 -13
  6. package/dist/RouterCollectionsStudioView-CwTpdzWF.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/ClearFilterSortButton.d.ts +7 -1
  10. package/dist/components/CollectionViewBinding/EntityCardBinding.d.ts +3 -1
  11. package/dist/components/CollectionViewBinding/SearchExplanation.d.ts +48 -0
  12. package/dist/components/CollectionViewBinding/SearchHighlight.d.ts +101 -0
  13. package/dist/components/CollectionViewBinding/SlotValue.d.ts +26 -0
  14. package/dist/components/CollectionViewBinding/SortButton.d.ts +7 -1
  15. package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +17 -6
  16. package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +1 -1
  17. package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +26 -1
  18. package/dist/components/DetailViewBinding.d.ts +7 -0
  19. package/dist/components/DrawerNavigationGroup.d.ts +4 -2
  20. package/dist/components/DrawerNavigationItem.d.ts +13 -5
  21. package/dist/components/EditViewBinding.d.ts +22 -7
  22. package/dist/components/EntityIdentityBar.d.ts +24 -1
  23. package/dist/components/RelationSelector.d.ts +13 -0
  24. package/dist/data_export/export/export.d.ts +7 -0
  25. package/dist/data_export/export/fetch_export_data.d.ts +48 -0
  26. package/dist/data_export/export/index.d.ts +1 -0
  27. package/dist/data_import/utils/csv.d.ts +30 -0
  28. package/dist/data_import/utils/index.d.ts +2 -0
  29. package/dist/data_import/utils/save_entities.d.ts +38 -0
  30. package/dist/data_import/utils/transforms.d.ts +9 -0
  31. package/dist/editor.js +3 -0
  32. package/dist/editor.js.map +1 -1
  33. package/dist/{export-C-XG3aRh.js → export-L53WektO.js} +175 -39
  34. package/dist/export-L53WektO.js.map +1 -0
  35. package/dist/form/form_utils.d.ts +38 -1
  36. package/dist/{history-DfMuvPfr.js → history-D5SKygpJ.js} +2 -2
  37. package/dist/{history-DfMuvPfr.js.map → history-D5SKygpJ.js.map} +1 -1
  38. package/dist/{import-B0GyllB0.js → import-C_4edkoD.js} +5 -4
  39. package/dist/import-C_4edkoD.js.map +1 -0
  40. package/dist/index.d.ts +2 -2
  41. package/dist/index.js +96 -80
  42. package/dist/index.js.map +1 -1
  43. package/dist/preview/components/date_fns_locales.d.ts +9 -0
  44. package/dist/util/view_constants.d.ts +24 -0
  45. package/dist/util/view_mode.d.ts +18 -0
  46. package/dist/{util-DHNXodBo.js → util-C-D5yD2n.js} +2173 -673
  47. package/dist/util-C-D5yD2n.js.map +1 -0
  48. package/package.json +10 -9
  49. package/src/collection_editor/serializable_types.ts +1 -0
  50. package/src/collection_editor/ui/collection_editor/AICollectionGeneratorPopover.tsx +8 -3
  51. package/src/collection_editor/ui/collection_editor/CollectionDetailsForm.tsx +5 -2
  52. package/src/collection_editor/ui/collection_editor/CollectionJsonImportDialog.tsx +1 -0
  53. package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +9 -8
  54. package/src/collection_editor/ui/collection_editor/CollectionsStudioView.tsx +9 -11
  55. package/src/collection_editor/ui/collection_editor/GeneralSettingsForm.tsx +5 -2
  56. package/src/collection_editor/ui/collection_editor/properties/MapPropertyField.tsx +1 -1
  57. package/src/collection_editor/ui/collection_editor/properties/advanced/AdvancedPropertyValidation.tsx +5 -2
  58. package/src/collection_editor/ui/collection_editor/properties/conditions/ConditionsEditor.tsx +1 -0
  59. package/src/collection_editor/useJsonCollectionsConfigController.tsx +92 -84
  60. package/src/collection_editor/useLocalCollectionsConfigController.tsx +10 -3
  61. package/src/components/ClearFilterSortButton.tsx +16 -7
  62. package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +2 -2
  63. package/src/components/CollectionTableBinding/PropertyTableCell.tsx +2 -2
  64. package/src/components/CollectionTableBinding/column_utils.tsx +15 -6
  65. package/src/components/CollectionTableBinding/internal/popup_field/PopupFormField.tsx +2 -2
  66. package/src/components/CollectionTableBinding/table_bindings.tsx +7 -1
  67. package/src/components/CollectionViewBinding/BoardCardBinding.tsx +18 -1
  68. package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +13 -7
  69. package/src/components/CollectionViewBinding/CollectionCardViewBinding.tsx +2 -1
  70. package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +816 -335
  71. package/src/components/CollectionViewBinding/CollectionViewActions.tsx +1 -1
  72. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +26 -6
  73. package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +9 -0
  74. package/src/components/CollectionViewBinding/EntityCardBinding.tsx +45 -10
  75. package/src/components/CollectionViewBinding/SearchExplanation.tsx +106 -0
  76. package/src/components/CollectionViewBinding/SearchHighlight.tsx +311 -0
  77. package/src/components/CollectionViewBinding/SlotValue.tsx +96 -0
  78. package/src/components/CollectionViewBinding/SortButton.tsx +16 -4
  79. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +23 -9
  80. package/src/components/CollectionViewBinding/SplitListShowButton.tsx +3 -3
  81. package/src/components/CollectionViewBinding/SplitListView.tsx +36 -9
  82. package/src/components/CollectionViewBinding/ViewModeToggle.tsx +10 -5
  83. package/src/components/CollectionViewBinding/useBoardDataController.tsx +11 -3
  84. package/src/components/CollectionViewBinding/usePreviewSlots.ts +76 -24
  85. package/src/components/CollectionViewBinding/utils.ts +7 -4
  86. package/src/components/DefaultDrawer.tsx +7 -0
  87. package/src/components/DetailViewBinding.tsx +33 -9
  88. package/src/components/DrawerNavigationGroup.tsx +12 -32
  89. package/src/components/DrawerNavigationItem.tsx +33 -12
  90. package/src/components/EditViewBinding.tsx +175 -28
  91. package/src/components/EntityIdentityBar.tsx +109 -17
  92. package/src/components/EntityInspector.tsx +24 -6
  93. package/src/components/EntityViewBinding.tsx +4 -2
  94. package/src/components/ReferenceTable/SelectionTableBinding.tsx +4 -2
  95. package/src/components/RelationSelector.tsx +61 -11
  96. package/src/components/SearchIconsView.tsx +14 -33
  97. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +1 -0
  98. package/src/components/SidePanelBinding.tsx +36 -25
  99. package/src/components/UserSelector.tsx +52 -9
  100. package/src/components/history/LastEditedByIndicator.tsx +7 -12
  101. package/src/data_export/export/ExportCollectionAction.tsx +101 -48
  102. package/src/data_export/export/export.ts +63 -16
  103. package/src/data_export/export/fetch_export_data.ts +90 -0
  104. package/src/data_export/export/index.ts +1 -0
  105. package/src/data_import/components/DataNewPropertiesMapping.tsx +20 -3
  106. package/src/data_import/components/ImportNewPropertyFieldPreview.tsx +1 -0
  107. package/src/data_import/components/ImportSaveInProgress.tsx +35 -36
  108. package/src/data_import/import/ImportCollectionAction.tsx +13 -2
  109. package/src/data_import/utils/csv.ts +153 -0
  110. package/src/data_import/utils/data.ts +16 -2
  111. package/src/data_import/utils/file_to_json.ts +77 -6
  112. package/src/data_import/utils/index.ts +2 -0
  113. package/src/data_import/utils/save_entities.ts +138 -0
  114. package/src/data_import/utils/transforms.ts +23 -0
  115. package/src/editor/components/image-bubble.tsx +2 -0
  116. package/src/editor/selectors/link-selector.tsx +1 -0
  117. package/src/form/EntityForm.tsx +12 -3
  118. package/src/form/EntityFormBinding.tsx +8 -12
  119. package/src/form/PropertyFieldBinding.tsx +2 -2
  120. package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +2 -2
  121. package/src/form/field_bindings/BinaryFieldBinding.tsx +1 -0
  122. package/src/form/field_bindings/BlockFieldBinding.tsx +3 -3
  123. package/src/form/field_bindings/DateTimeFieldBinding.tsx +4 -0
  124. package/src/form/field_bindings/KeyValueFieldBinding.tsx +3 -0
  125. package/src/form/field_bindings/MapFieldBinding.tsx +2 -2
  126. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +15 -2
  127. package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -3
  128. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +2 -2
  129. package/src/form/field_bindings/TextFieldBinding.tsx +18 -0
  130. package/src/form/field_bindings/VectorFieldBinding.tsx +1 -0
  131. package/src/form/form_utils.ts +78 -0
  132. package/src/index.ts +9 -1
  133. package/src/preview/PropertyPreview.tsx +7 -3
  134. package/src/preview/components/DatePreview.tsx +15 -2
  135. package/src/preview/components/ReferencePreview.tsx +5 -8
  136. package/src/preview/components/RelationPreview.tsx +8 -7
  137. package/src/preview/components/date_fns_locales.ts +125 -0
  138. package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +6 -3
  139. package/src/util/navigation_utils.ts +4 -2
  140. package/src/util/property_utils.tsx +6 -1
  141. package/src/util/view_constants.ts +27 -0
  142. package/src/util/view_mode.ts +36 -0
  143. package/dist/CollectionEditorDialog-7b-kSLno.js.map +0 -1
  144. package/dist/PropertyEditView-C-31uvpG.js.map +0 -1
  145. package/dist/RouterCollectionsStudioView-BJylABq3.js.map +0 -1
  146. package/dist/export-C-XG3aRh.js.map +0 -1
  147. package/dist/import-B0GyllB0.js.map +0 -1
  148. package/dist/util-DHNXodBo.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());
@@ -1458,6 +1477,7 @@ function EntityIdHeaderWidget({
1458
1477
  <div className="flex p-2 w-full gap-2">
1459
1478
  <TextField
1460
1479
  autoFocus={openPopup}
1480
+ aria-label={t("find_entity_by_id")}
1461
1481
  placeholder={t("find_entity_by_id")}
1462
1482
  size="small"
1463
1483
  onChange={(e) => {
@@ -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
  ];
@@ -17,9 +17,13 @@ import {
17
17
  import { useAnalyticsController } from "@rebasepro/app";
18
18
  import { IconForView } from "@rebasepro/app";
19
19
  import { useCollectionSlotKeys, useEntitySlots } from "./usePreviewSlots";
20
- import { SlotValue } from "./SlotValue";
20
+ import { Highlighted } from "./SearchHighlight";
21
+ import { useSearchExplanation, MatchExplanation, fieldLabel } from "./SearchExplanation";
22
+ import { SlotValue, TagChips } from "./SlotValue";
21
23
 
22
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;
23
27
  entity: Entity<M>;
24
28
  collection: AdminCollection<M>;
25
29
  onClick?: (entity: Entity<M>) => void;
@@ -45,7 +49,8 @@ export function EntityCardBinding<M extends Record<string, unknown> = Record<str
45
49
  highlighted,
46
50
  onSelectionChange,
47
51
  selectionEnabled,
48
- size = "m"
52
+ size = "m",
53
+ searchString
49
54
  }: EntityCardBindingProps<M>) {
50
55
  const authController = useAuthController();
51
56
  const analyticsController = useAnalyticsController();
@@ -63,6 +68,15 @@ export function EntityCardBinding<M extends Record<string, unknown> = Record<str
63
68
  slotKeys
64
69
  );
65
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
78
+ );
79
+
66
80
  const handleClick = (e?: React.MouseEvent) => {
67
81
  // Cmd+click (Mac) or Ctrl+click (Windows) toggles selection
68
82
  if (e && (e.metaKey || e.ctrlKey) && selectionEnabled) {
@@ -152,7 +166,9 @@ export function EntityCardBinding<M extends Record<string, unknown> = Record<str
152
166
  {/* Title slot */}
153
167
  <div className="truncate my-1 text-sm font-medium min-h-[20px]">
154
168
  {slots.title ? (
155
- <SlotValue slot={slots.title} size="small"/>
169
+ terms.length > 0 && typeof slots.title.value === "string"
170
+ ? <Highlighted text={slots.title.value} terms={terms}/>
171
+ : <SlotValue slot={slots.title} size="small"/>
156
172
  ) : (
157
173
  <Typography variant="body2" className="text-surface-500">
158
174
  {entity.id}
@@ -160,10 +176,19 @@ export function EntityCardBinding<M extends Record<string, unknown> = Record<str
160
176
  )}
161
177
  </div>
162
178
 
163
- {/* Subtitle slot */}
164
- {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 && (
165
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">
166
- <SlotValue slot={slots.subtitle} size="small"/>
189
+ {terms.length > 0 && typeof slots.subtitle.value === "string"
190
+ ? <Highlighted text={slots.subtitle.value} terms={terms}/>
191
+ : <SlotValue slot={slots.subtitle} size="small"/>}
167
192
  </div>
168
193
  )}
169
194
 
@@ -190,10 +215,20 @@ export function EntityCardBinding<M extends Record<string, unknown> = Record<str
190
215
  </div>
191
216
  )}
192
217
 
193
- {/* Status slot */}
194
- {slots.status && (
195
- <div className="mt-1">
196
- <SlotValue slot={slots.status} size="small"/>
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}
197
232
  </div>
198
233
  )}
199
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
+ }
@@ -0,0 +1,311 @@
1
+ import React, { useMemo } from "react";
2
+ import type { SearchMatch } from "@rebasepro/types";
3
+
4
+ /**
5
+ * Showing a searcher *why* a row is in their results.
6
+ *
7
+ * A ranked list answers "which rows" and never "why this one". When the term is
8
+ * in the title that is self-evident; when it is in a field the list does not
9
+ * show — a certification, a nested answer, a note — the row looks arbitrary and
10
+ * the only way to find out is to open it. That is the whole reason this exists.
11
+ *
12
+ * Two jobs, and they are deliberately separate:
13
+ *
14
+ * 1. {@link Highlighted} marks the term inside a value the list already shows.
15
+ * 2. {@link offSlotMatch} finds a match in a field that has no slot, so the
16
+ * caller can put it where the subtitle would have gone.
17
+ */
18
+
19
+ /** `<mark>` is Postgres's delimiter, and what `ts_headline` emits. */
20
+ const MARK = /(<mark>.*?<\/mark>)/g;
21
+
22
+ /**
23
+ * Render a `ts_headline` snippet: text with `<mark>` around each hit.
24
+ *
25
+ * Split-and-render rather than `dangerouslySetInnerHTML`. The text around the
26
+ * marks is whatever a user typed into their own record, and the only markup it
27
+ * is allowed to carry is the highlight Postgres put there. Anything else stays
28
+ * inert text.
29
+ */
30
+ /**
31
+ * Context kept before and after a hit, in characters.
32
+ *
33
+ * Asymmetric on purpose. The snippet renders on one truncated line, so anything
34
+ * before the hit competes with the hit itself for the width available — with a
35
+ * generous lead-in the mark is pushed off the right edge and the reader sees a
36
+ * fragment with nothing highlighted in it, which is worse than no snippet at
37
+ * all. Lead with just enough to not start abruptly, then run on.
38
+ */
39
+ const SNIPPET_LEAD = 16;
40
+ const SNIPPET_TRAIL = 90;
41
+
42
+ /**
43
+ * Pull the first mark near the front of the snippet.
44
+ *
45
+ * The snippet renders on one truncated line whose width is not knowable here —
46
+ * a list column is wide, the same list beside an open record is not. Whatever
47
+ * precedes the mark competes with it for that width, and when the mark loses
48
+ * the reader gets a fragment of their record with nothing highlighted in it,
49
+ * which reads as the feature being broken rather than as a narrow column.
50
+ *
51
+ * `ts_headline` centres its fragment on the match, so roughly half the window
52
+ * is lead-in; that is the right shape for a paragraph and the wrong one for a
53
+ * line. Trimming here rather than asking Postgres for a smaller window keeps
54
+ * the trailing context, which is what actually explains the hit — and applies
55
+ * the same shape to the snippets built in the browser, so a row looks the same
56
+ * whichever path produced it.
57
+ */
58
+ export function trimSnippetLead(text: string, lead: number = SNIPPET_LEAD): string {
59
+ const at = text.indexOf("<mark>");
60
+ if (at < 0 || at <= lead) return text;
61
+ // Trim the lead-in only. Running the word-boundary strip over the whole
62
+ // remainder eats the mark itself when the lead is short enough that the
63
+ // slice opens on `<mark>`, which has no space in it to stop at.
64
+ const head = text.slice(at - lead, at).replace(/^\S*\s/, "");
65
+ return "…" + head + text.slice(at);
66
+ }
67
+
68
+ export function Snippet({ text, className }: { text: string; className?: string }) {
69
+ const parts = useMemo(() => trimSnippetLead(text).split(MARK), [text]);
70
+ return (
71
+ <span className={className}>
72
+ {parts.map((part, i) =>
73
+ part.startsWith("<mark>")
74
+ ? (
75
+ <mark
76
+ key={i}
77
+ className="bg-amber-200/70 dark:bg-amber-400/30 text-inherit rounded-[3px] px-[1px]"
78
+ >
79
+ {part.slice(6, -7)}
80
+ </mark>
81
+ )
82
+ : <React.Fragment key={i}>{part}</React.Fragment>
83
+ )}
84
+ </span>
85
+ );
86
+ }
87
+
88
+ /**
89
+ * Mark every occurrence of the searched terms inside a plain value.
90
+ *
91
+ * Used for values the list already renders, where the server's snippet would be
92
+ * the wrong thing to show: the snippet is truncated to a fragment and folded to
93
+ * the indexed form, while the cell should keep the record's real text. So the
94
+ * terms are re-matched here, on the display value.
95
+ *
96
+ * Deliberately dumber than the server's matching, and it has to be: this cannot
97
+ * stem or unaccent without reimplementing the text search configuration in the
98
+ * browser and getting it subtly different. It marks what it can find as a
99
+ * prefix, case- and accent-insensitively, and marks nothing when it cannot —
100
+ * a highlight that is merely absent reads as "no hit in this field", which is
101
+ * the honest answer for a stem the browser cannot reproduce.
102
+ */
103
+ export function Highlighted({ text, terms, className }: { text: string; terms: string[]; className?: string }) {
104
+ const segments = useMemo(() => splitOnTerms(text, terms), [text, terms]);
105
+ if (segments.length <= 1) return <span className={className}>{text}</span>;
106
+ return (
107
+ <span className={className}>
108
+ {segments.map((seg, i) =>
109
+ seg.hit
110
+ ? (
111
+ <mark
112
+ key={i}
113
+ className="bg-amber-200/70 dark:bg-amber-400/30 text-inherit rounded-[3px] px-[1px]"
114
+ >
115
+ {seg.text}
116
+ </mark>
117
+ )
118
+ : <React.Fragment key={i}>{seg.text}</React.Fragment>
119
+ )}
120
+ </span>
121
+ );
122
+ }
123
+
124
+ /** Strip diacritics so `gestion` marks `gestión`, mirroring the index's unaccent. */
125
+ const fold = (value: string): string =>
126
+ value.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
127
+
128
+ interface Segment { text: string; hit: boolean }
129
+
130
+ /**
131
+ * Split `text` into hit / non-hit runs.
132
+ *
133
+ * Matches on the *folded* form but slices the original, so the marked text
134
+ * keeps its accents and case — `Gestión`, not `gestion`. Folding is
135
+ * length-preserving for the diacritics this handles, which is what makes the
136
+ * indices transferable; a fold that changed length would misalign the slices.
137
+ */
138
+ export function splitOnTerms(text: string, terms: string[]): Segment[] {
139
+ const haystack = fold(text);
140
+ const needles = terms.map(fold).filter(t => t.length >= 2);
141
+ if (needles.length === 0) return [{ text, hit: false }];
142
+
143
+ // Word-start matches only. A search for "iso" marking the "iso" inside
144
+ // "revisor" is noise, and reads as a bug rather than a feature.
145
+ const bounds: [number, number][] = [];
146
+ for (const needle of needles) {
147
+ let from = 0;
148
+ for (;;) {
149
+ const at = haystack.indexOf(needle, from);
150
+ if (at === -1) break;
151
+ const before = at === 0 ? " " : haystack[at - 1];
152
+ if (!/[\p{L}\p{N}]/u.test(before)) bounds.push([at, at + needle.length]);
153
+ from = at + needle.length;
154
+ }
155
+ }
156
+ if (bounds.length === 0) return [{ text, hit: false }];
157
+
158
+ // Merge overlaps so two terms hitting the same run mark it once.
159
+ bounds.sort((a, b) => a[0] - b[0]);
160
+ const merged: [number, number][] = [];
161
+ for (const [start, end] of bounds) {
162
+ const last = merged[merged.length - 1];
163
+ if (last && start <= last[1]) last[1] = Math.max(last[1], end);
164
+ else merged.push([start, end]);
165
+ }
166
+
167
+ const segments: Segment[] = [];
168
+ let cursor = 0;
169
+ for (const [start, end] of merged) {
170
+ if (start > cursor) segments.push({ text: text.slice(cursor, start), hit: false });
171
+ segments.push({ text: text.slice(start, end), hit: true });
172
+ cursor = end;
173
+ }
174
+ if (cursor < text.length) segments.push({ text: text.slice(cursor), hit: false });
175
+ return segments;
176
+ }
177
+
178
+ /**
179
+ * The terms a searcher typed, as things to look for in a rendered value.
180
+ *
181
+ * Quoted phrases stay whole; `-excluded` terms are dropped, since marking a
182
+ * word the user asked *not* to see would be actively wrong. `websearch_to_tsquery`
183
+ * reads the same syntax on the server.
184
+ */
185
+ export function searchTerms(searchString: string | undefined): string[] {
186
+ if (!searchString) return [];
187
+ const terms: string[] = [];
188
+ for (const [, phrase, bare] of searchString.matchAll(/"([^"]+)"|(\S+)/g)) {
189
+ const term = phrase ?? bare;
190
+ if (!term || term.startsWith("-")) continue;
191
+ if (/^(or|and)$/i.test(term)) continue;
192
+ terms.push(term);
193
+ }
194
+ return terms;
195
+ }
196
+
197
+ /**
198
+ * The best match to show when the term is nowhere the list already displays.
199
+ *
200
+ * `shownKeys` are the property paths the row is already rendering, so a match
201
+ * there needs no explaining — {@link Highlighted} marks it in place and the
202
+ * subtitle keeps its own job. Anything else is invisible, and one of those is
203
+ * what the subtitle slot gets replaced with.
204
+ *
205
+ * The first such match wins, and the server returns them in the order the
206
+ * collection declared its `search` fields — so the field an author put first is
207
+ * the one a reader sees, rather than whichever the database aggregated first.
208
+ */
209
+ export function offSlotMatch(
210
+ matches: SearchMatch[] | undefined,
211
+ shownKeys: (string | undefined)[]
212
+ ): SearchMatch | undefined {
213
+ if (!matches || matches.length === 0) return undefined;
214
+ const shown = new Set(shownKeys.filter((k): k is string => Boolean(k)));
215
+ return matches.find(m => !shown.has(m.field));
216
+ }
217
+
218
+ // ── Finding the match locally ───────────────────────────────────────────────
219
+
220
+ /**
221
+ * Where a search term appears in a row, computed from the row itself.
222
+ *
223
+ * The server can say this too — `_matches`, from `ts_headline` — and its answer
224
+ * is better, because it knows the stemming the index used. This exists because
225
+ * the admin cannot rely on getting it: rows reach the panel over three
226
+ * different transports (REST, the realtime socket, the offline cache) and only
227
+ * one of them carries query metadata. A feature that explains a result on some
228
+ * loads and not others is worse than one that always gives a slightly simpler
229
+ * answer.
230
+ *
231
+ * So: use the server's when it is there, fall back to this. Both produce the
232
+ * same `{ field, snippet }` shape, and this one marks with the same `<mark>`.
233
+ */
234
+ export function localRowMatch(
235
+ values: Record<string, unknown> | undefined,
236
+ properties: Record<string, unknown> | undefined,
237
+ terms: string[],
238
+ shownKeys: (string | undefined)[]
239
+ ): SearchMatch | undefined {
240
+ if (!values || !properties || terms.length === 0) return undefined;
241
+ const shown = new Set(shownKeys.filter((k): k is string => Boolean(k)));
242
+
243
+ // Declared order, so the field an author put first wins — the same rule the
244
+ // server's `_matches` follows.
245
+ for (const [key, property] of Object.entries(properties)) {
246
+ if (shown.has(key)) continue;
247
+ const found = findInValue(values[key], terms, key, property as { type?: string });
248
+ if (found) return found;
249
+ }
250
+ return undefined;
251
+ }
252
+
253
+ function findInValue(
254
+ value: unknown,
255
+ terms: string[],
256
+ path: string,
257
+ property: { type?: string } | undefined
258
+ ): SearchMatch | undefined {
259
+ // An enum renders as a chip from a fixed vocabulary; marking inside one
260
+ // reads as noise, and `where` is the right tool for those anyway.
261
+ if (property && "enum" in property && property.enum) return undefined;
262
+
263
+ if (typeof value === "string") {
264
+ const snippet = snippetAround(value, terms);
265
+ return snippet ? { field: path, snippet } : undefined;
266
+ }
267
+ if (Array.isArray(value)) {
268
+ for (const item of value) {
269
+ const found = findInValue(item, terms, path, undefined);
270
+ if (found) return found;
271
+ }
272
+ return undefined;
273
+ }
274
+ if (value && typeof value === "object") {
275
+ // Nested maps: report the path the author would recognise, which is the
276
+ // dotted one the `search` block uses.
277
+ for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
278
+ const found = findInValue(nested, terms, `${path}.${key}`, undefined);
279
+ if (found) return found;
280
+ }
281
+ }
282
+ return undefined;
283
+ }
284
+
285
+ /**
286
+ * The text around the first hit, with every hit in view marked.
287
+ *
288
+ * Trimmed to a readable window rather than the whole field: a match in a long
289
+ * body should show the sentence it is in, not the first line of the document,
290
+ * and certainly not the document.
291
+ */
292
+ export function snippetAround(text: string, terms: string[]): string | undefined {
293
+ const segments = splitOnTerms(text, terms);
294
+ const firstHit = segments.findIndex(s => s.hit);
295
+ if (firstHit === -1) return undefined;
296
+
297
+ const before = segments.slice(0, firstHit).map(s => s.text).join("");
298
+ const head = before.length > SNIPPET_LEAD
299
+ // Start at a word boundary so the snippet does not open mid-word.
300
+ ? "…" + before.slice(before.length - SNIPPET_LEAD).replace(/^\S*\s/, "")
301
+ : before;
302
+
303
+ let out = head;
304
+ for (let i = firstHit; i < segments.length; i++) {
305
+ const seg = segments[i];
306
+ out += seg.hit ? `<mark>${seg.text}</mark>` : seg.text;
307
+ // Enough context after the hit to read it, then stop.
308
+ if (out.length > head.length + SNIPPET_TRAIL) { out += "…"; break; }
309
+ }
310
+ return out.trim();
311
+ }