@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.
- package/dist/{CollectionEditorDialog-7b-kSLno.js → CollectionEditorDialog-CSheud_E.js} +19 -9
- package/dist/CollectionEditorDialog-CSheud_E.js.map +1 -0
- package/dist/{PropertyEditView-C-31uvpG.js → PropertyEditView-CWZlC1gq.js} +5 -5
- package/dist/PropertyEditView-CWZlC1gq.js.map +1 -0
- package/dist/{RouterCollectionsStudioView-BJylABq3.js → RouterCollectionsStudioView-CwTpdzWF.js} +9 -13
- package/dist/RouterCollectionsStudioView-CwTpdzWF.js.map +1 -0
- package/dist/collection_editor/serializable_types.d.ts +1 -0
- package/dist/collection_editor_ui.js +4 -4
- package/dist/components/ClearFilterSortButton.d.ts +7 -1
- package/dist/components/CollectionViewBinding/EntityCardBinding.d.ts +3 -1
- package/dist/components/CollectionViewBinding/SearchExplanation.d.ts +48 -0
- package/dist/components/CollectionViewBinding/SearchHighlight.d.ts +101 -0
- package/dist/components/CollectionViewBinding/SlotValue.d.ts +26 -0
- package/dist/components/CollectionViewBinding/SortButton.d.ts +7 -1
- package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +17 -6
- package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +1 -1
- package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +26 -1
- package/dist/components/DetailViewBinding.d.ts +7 -0
- package/dist/components/DrawerNavigationGroup.d.ts +4 -2
- package/dist/components/DrawerNavigationItem.d.ts +13 -5
- package/dist/components/EditViewBinding.d.ts +22 -7
- package/dist/components/EntityIdentityBar.d.ts +24 -1
- package/dist/components/RelationSelector.d.ts +13 -0
- package/dist/data_export/export/export.d.ts +7 -0
- package/dist/data_export/export/fetch_export_data.d.ts +48 -0
- package/dist/data_export/export/index.d.ts +1 -0
- package/dist/data_import/utils/csv.d.ts +30 -0
- package/dist/data_import/utils/index.d.ts +2 -0
- package/dist/data_import/utils/save_entities.d.ts +38 -0
- package/dist/data_import/utils/transforms.d.ts +9 -0
- package/dist/editor.js +3 -0
- package/dist/editor.js.map +1 -1
- package/dist/{export-C-XG3aRh.js → export-L53WektO.js} +175 -39
- package/dist/export-L53WektO.js.map +1 -0
- package/dist/form/form_utils.d.ts +38 -1
- package/dist/{history-DfMuvPfr.js → history-D5SKygpJ.js} +2 -2
- package/dist/{history-DfMuvPfr.js.map → history-D5SKygpJ.js.map} +1 -1
- package/dist/{import-B0GyllB0.js → import-C_4edkoD.js} +5 -4
- package/dist/import-C_4edkoD.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +96 -80
- package/dist/index.js.map +1 -1
- package/dist/preview/components/date_fns_locales.d.ts +9 -0
- package/dist/util/view_constants.d.ts +24 -0
- package/dist/util/view_mode.d.ts +18 -0
- package/dist/{util-DHNXodBo.js → util-C-D5yD2n.js} +2173 -673
- package/dist/util-C-D5yD2n.js.map +1 -0
- package/package.json +10 -9
- package/src/collection_editor/serializable_types.ts +1 -0
- package/src/collection_editor/ui/collection_editor/AICollectionGeneratorPopover.tsx +8 -3
- package/src/collection_editor/ui/collection_editor/CollectionDetailsForm.tsx +5 -2
- package/src/collection_editor/ui/collection_editor/CollectionJsonImportDialog.tsx +1 -0
- package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +9 -8
- package/src/collection_editor/ui/collection_editor/CollectionsStudioView.tsx +9 -11
- package/src/collection_editor/ui/collection_editor/GeneralSettingsForm.tsx +5 -2
- package/src/collection_editor/ui/collection_editor/properties/MapPropertyField.tsx +1 -1
- package/src/collection_editor/ui/collection_editor/properties/advanced/AdvancedPropertyValidation.tsx +5 -2
- package/src/collection_editor/ui/collection_editor/properties/conditions/ConditionsEditor.tsx +1 -0
- package/src/collection_editor/useJsonCollectionsConfigController.tsx +92 -84
- package/src/collection_editor/useLocalCollectionsConfigController.tsx +10 -3
- package/src/components/ClearFilterSortButton.tsx +16 -7
- package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +2 -2
- package/src/components/CollectionTableBinding/PropertyTableCell.tsx +2 -2
- package/src/components/CollectionTableBinding/column_utils.tsx +15 -6
- package/src/components/CollectionTableBinding/internal/popup_field/PopupFormField.tsx +2 -2
- package/src/components/CollectionTableBinding/table_bindings.tsx +7 -1
- package/src/components/CollectionViewBinding/BoardCardBinding.tsx +18 -1
- package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +13 -7
- package/src/components/CollectionViewBinding/CollectionCardViewBinding.tsx +2 -1
- package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +816 -335
- package/src/components/CollectionViewBinding/CollectionViewActions.tsx +1 -1
- package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +26 -6
- package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +9 -0
- package/src/components/CollectionViewBinding/EntityCardBinding.tsx +45 -10
- package/src/components/CollectionViewBinding/SearchExplanation.tsx +106 -0
- package/src/components/CollectionViewBinding/SearchHighlight.tsx +311 -0
- package/src/components/CollectionViewBinding/SlotValue.tsx +96 -0
- package/src/components/CollectionViewBinding/SortButton.tsx +16 -4
- package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +23 -9
- package/src/components/CollectionViewBinding/SplitListShowButton.tsx +3 -3
- package/src/components/CollectionViewBinding/SplitListView.tsx +36 -9
- package/src/components/CollectionViewBinding/ViewModeToggle.tsx +10 -5
- package/src/components/CollectionViewBinding/useBoardDataController.tsx +11 -3
- package/src/components/CollectionViewBinding/usePreviewSlots.ts +76 -24
- package/src/components/CollectionViewBinding/utils.ts +7 -4
- package/src/components/DefaultDrawer.tsx +7 -0
- package/src/components/DetailViewBinding.tsx +33 -9
- package/src/components/DrawerNavigationGroup.tsx +12 -32
- package/src/components/DrawerNavigationItem.tsx +33 -12
- package/src/components/EditViewBinding.tsx +175 -28
- package/src/components/EntityIdentityBar.tsx +109 -17
- package/src/components/EntityInspector.tsx +24 -6
- package/src/components/EntityViewBinding.tsx +4 -2
- package/src/components/ReferenceTable/SelectionTableBinding.tsx +4 -2
- package/src/components/RelationSelector.tsx +61 -11
- package/src/components/SearchIconsView.tsx +14 -33
- package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +1 -0
- package/src/components/SidePanelBinding.tsx +36 -25
- package/src/components/UserSelector.tsx +52 -9
- package/src/components/history/LastEditedByIndicator.tsx +7 -12
- package/src/data_export/export/ExportCollectionAction.tsx +101 -48
- package/src/data_export/export/export.ts +63 -16
- package/src/data_export/export/fetch_export_data.ts +90 -0
- package/src/data_export/export/index.ts +1 -0
- package/src/data_import/components/DataNewPropertiesMapping.tsx +20 -3
- package/src/data_import/components/ImportNewPropertyFieldPreview.tsx +1 -0
- package/src/data_import/components/ImportSaveInProgress.tsx +35 -36
- package/src/data_import/import/ImportCollectionAction.tsx +13 -2
- package/src/data_import/utils/csv.ts +153 -0
- package/src/data_import/utils/data.ts +16 -2
- package/src/data_import/utils/file_to_json.ts +77 -6
- package/src/data_import/utils/index.ts +2 -0
- package/src/data_import/utils/save_entities.ts +138 -0
- package/src/data_import/utils/transforms.ts +23 -0
- package/src/editor/components/image-bubble.tsx +2 -0
- package/src/editor/selectors/link-selector.tsx +1 -0
- package/src/form/EntityForm.tsx +12 -3
- package/src/form/EntityFormBinding.tsx +8 -12
- package/src/form/PropertyFieldBinding.tsx +2 -2
- package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +2 -2
- package/src/form/field_bindings/BinaryFieldBinding.tsx +1 -0
- package/src/form/field_bindings/BlockFieldBinding.tsx +3 -3
- package/src/form/field_bindings/DateTimeFieldBinding.tsx +4 -0
- package/src/form/field_bindings/KeyValueFieldBinding.tsx +3 -0
- package/src/form/field_bindings/MapFieldBinding.tsx +2 -2
- package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +15 -2
- package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -3
- package/src/form/field_bindings/StorageUploadFieldBinding.tsx +2 -2
- package/src/form/field_bindings/TextFieldBinding.tsx +18 -0
- package/src/form/field_bindings/VectorFieldBinding.tsx +1 -0
- package/src/form/form_utils.ts +78 -0
- package/src/index.ts +9 -1
- package/src/preview/PropertyPreview.tsx +7 -3
- package/src/preview/components/DatePreview.tsx +15 -2
- package/src/preview/components/ReferencePreview.tsx +5 -8
- package/src/preview/components/RelationPreview.tsx +8 -7
- package/src/preview/components/date_fns_locales.ts +125 -0
- package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +6 -3
- package/src/util/navigation_utils.ts +4 -2
- package/src/util/property_utils.tsx +6 -1
- package/src/util/view_constants.ts +27 -0
- package/src/util/view_mode.ts +36 -0
- package/dist/CollectionEditorDialog-7b-kSLno.js.map +0 -1
- package/dist/PropertyEditView-C-31uvpG.js.map +0 -1
- package/dist/RouterCollectionsStudioView-BJylABq3.js.map +0 -1
- package/dist/export-C-XG3aRh.js.map +0 -1
- package/dist/import-B0GyllB0.js.map +0 -1
- 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
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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((
|
|
298
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|