@asteby/metacore-runtime-react 22.0.0 → 23.1.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/CHANGELOG.md +62 -0
- package/dist/dynamic-kanban.d.ts +20 -8
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +103 -102
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +60 -7
- package/dist/filter-chips.d.ts +60 -0
- package/dist/filter-chips.d.ts.map +1 -0
- package/dist/filter-chips.js +93 -0
- package/dist/use-dynamic-filters.d.ts.map +1 -1
- package/dist/use-dynamic-filters.js +25 -1
- package/dist/use-facet-loaders.d.ts +23 -7
- package/dist/use-facet-loaders.d.ts.map +1 -1
- package/dist/use-facet-loaders.js +49 -8
- package/package.json +3 -3
- package/src/__tests__/dynamic-kanban.test.tsx +141 -0
- package/src/__tests__/dynamic-table-filters.test.tsx +102 -0
- package/src/__tests__/filter-chips.test.tsx +119 -0
- package/src/__tests__/use-dynamic-filters.test.tsx +65 -4
- package/src/dynamic-kanban.tsx +189 -199
- package/src/dynamic-table.tsx +69 -6
- package/src/filter-chips.tsx +175 -0
- package/src/use-dynamic-filters.ts +26 -1
- package/src/use-facet-loaders.ts +76 -9
package/src/dynamic-table.tsx
CHANGED
|
@@ -67,6 +67,7 @@ import { useApi, useCurrentBranch } from './api-context'
|
|
|
67
67
|
import type { ColumnFilterConfig, GetDynamicColumns } from './dynamic-columns-shim'
|
|
68
68
|
import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns'
|
|
69
69
|
import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders'
|
|
70
|
+
import { FilterChipsRow, translateOptionLabels, type FilterChipField } from './filter-chips'
|
|
70
71
|
import { OptionsContext } from './options-context'
|
|
71
72
|
import type { TableMetadata, ApiResponse } from './types'
|
|
72
73
|
import { getSearchableColumnKeys } from './column-visibility'
|
|
@@ -536,11 +537,15 @@ export function DynamicTable({
|
|
|
536
537
|
// getDynamicColumns forwards `loadOptions` into the column meta). Facets base
|
|
537
538
|
// derived off the list endpoint exactly like the aggregate endpoint below.
|
|
538
539
|
const facetsBase = endpoint ? `${endpoint}/facets` : model ? `/data/${model}/facets` : null
|
|
539
|
-
const getFacetLoader = useFacetLoaders(facetsBase)
|
|
540
|
+
const { getFacetLoader, prefetchFacets, facetOptions } = useFacetLoaders(facetsBase)
|
|
540
541
|
|
|
541
542
|
const columnFilterConfigs = useMemo(() => {
|
|
542
543
|
const map = new Map<string, ColumnFilterConfig>()
|
|
543
544
|
if (!metadata) return map
|
|
545
|
+
// Option labels arrive as manifest i18n keys; translate them here (the ui
|
|
546
|
+
// package has no i18n) so header filters, chips and value summaries show
|
|
547
|
+
// localized text. A raw value with no key falls through via defaultValue.
|
|
548
|
+
const tr = (label: string) => t(label, { defaultValue: label })
|
|
544
549
|
const stageOptions = (metadata.stages ?? []).map((s) => ({
|
|
545
550
|
label: s.label,
|
|
546
551
|
value: s.key,
|
|
@@ -574,18 +579,23 @@ export function DynamicTable({
|
|
|
574
579
|
if (loader) {
|
|
575
580
|
fType = 'facet'
|
|
576
581
|
loadOptions = loader
|
|
582
|
+
// Prewarmed values (from prefetchFacets) → the header filter
|
|
583
|
+
// opens with the list already there, no "Cargando…" flash.
|
|
584
|
+
options = facetOptions.get(f.column || f.key) ?? []
|
|
577
585
|
}
|
|
578
586
|
}
|
|
579
587
|
if (fType === 'select' && options.length === 0 && !f.searchEndpoint) continue
|
|
580
588
|
map.set(f.key, {
|
|
581
589
|
filterType: fType,
|
|
582
590
|
filterKey: f.column || f.key,
|
|
583
|
-
options,
|
|
591
|
+
options: translateOptionLabels(options, tr),
|
|
584
592
|
selectedValues: dynamicFilters[f.column || f.key] || [],
|
|
585
593
|
onFilterChange: handleDynamicFilterChange,
|
|
586
594
|
loading: f.searchEndpoint ? !filterOptionsMap.has(f.searchEndpoint) : false,
|
|
587
595
|
searchEndpoint: f.searchEndpoint,
|
|
588
|
-
loadOptions
|
|
596
|
+
loadOptions: loadOptions
|
|
597
|
+
? (q?: string) => loadOptions!(q).then((o) => translateOptionLabels(o, tr))
|
|
598
|
+
: undefined,
|
|
589
599
|
})
|
|
590
600
|
}
|
|
591
601
|
for (const c of metadata.columns ?? []) {
|
|
@@ -635,22 +645,64 @@ export function DynamicTable({
|
|
|
635
645
|
if (loader) {
|
|
636
646
|
filterType = 'facet'
|
|
637
647
|
loadOptions = loader
|
|
648
|
+
// Prewarmed values (from prefetchFacets) → instant open.
|
|
649
|
+
options = facetOptions.get(c.key) ?? []
|
|
638
650
|
}
|
|
639
651
|
}
|
|
640
652
|
|
|
641
653
|
map.set(c.key, {
|
|
642
654
|
filterType,
|
|
643
655
|
filterKey: c.key,
|
|
644
|
-
options,
|
|
656
|
+
options: translateOptionLabels(options, tr),
|
|
645
657
|
selectedValues: dynamicFilters[c.key] || [],
|
|
646
658
|
onFilterChange: handleDynamicFilterChange,
|
|
647
659
|
loading: hasEndpoint && !filterOptionsMap.has(c.searchEndpoint!),
|
|
648
660
|
searchEndpoint: c.searchEndpoint,
|
|
649
|
-
loadOptions
|
|
661
|
+
loadOptions: loadOptions
|
|
662
|
+
? (q?: string) => loadOptions!(q).then((o) => translateOptionLabels(o, tr))
|
|
663
|
+
: undefined,
|
|
650
664
|
})
|
|
651
665
|
}
|
|
652
666
|
return map
|
|
653
|
-
}, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange, facetsBase, getFacetLoader])
|
|
667
|
+
}, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange, facetsBase, getFacetLoader, facetOptions, t])
|
|
668
|
+
|
|
669
|
+
// Prewarm every facet field once the configs settle, so a text column's
|
|
670
|
+
// header filter opens instantly with values + counts (same as the kanban).
|
|
671
|
+
const facetFieldsSig = useMemo(() => {
|
|
672
|
+
const keys: string[] = []
|
|
673
|
+
for (const config of columnFilterConfigs.values()) {
|
|
674
|
+
if (config.filterType === 'facet') keys.push(config.filterKey)
|
|
675
|
+
}
|
|
676
|
+
return keys.join('|')
|
|
677
|
+
}, [columnFilterConfigs])
|
|
678
|
+
useEffect(() => {
|
|
679
|
+
if (!facetFieldsSig) return
|
|
680
|
+
prefetchFacets(facetFieldsSig.split('|'))
|
|
681
|
+
}, [facetFieldsSig, prefetchFacets])
|
|
682
|
+
|
|
683
|
+
// Active-filter chips (shared row with the kanban). One chip per field with a
|
|
684
|
+
// selection; the label is the translated filter/column label.
|
|
685
|
+
const activeFilterChips = useMemo<FilterChipField[]>(() => {
|
|
686
|
+
if (!metadata) return []
|
|
687
|
+
const out: FilterChipField[] = []
|
|
688
|
+
for (const [key, config] of columnFilterConfigs) {
|
|
689
|
+
if ((config.selectedValues?.length ?? 0) === 0) continue
|
|
690
|
+
const f = metadata.filters?.find((x) => x.key === key)
|
|
691
|
+
const c = metadata.columns?.find((x) => x.key === key)
|
|
692
|
+
const rawLabel = f?.label || c?.label || key
|
|
693
|
+
out.push({
|
|
694
|
+
key,
|
|
695
|
+
label: t(rawLabel, { defaultValue: rawLabel }),
|
|
696
|
+
config,
|
|
697
|
+
})
|
|
698
|
+
}
|
|
699
|
+
return out
|
|
700
|
+
}, [metadata, columnFilterConfigs, t])
|
|
701
|
+
|
|
702
|
+
const clearAllDynamicFilters = useCallback(() => {
|
|
703
|
+
setDynamicFilters({})
|
|
704
|
+
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }))
|
|
705
|
+
}, [])
|
|
654
706
|
|
|
655
707
|
const columns = useMemo(() => {
|
|
656
708
|
if (!viewMetadata) return []
|
|
@@ -771,6 +823,17 @@ export function DynamicTable({
|
|
|
771
823
|
</>
|
|
772
824
|
}
|
|
773
825
|
/>
|
|
826
|
+
{/* Active-filter chips — shared with the kanban. Quick
|
|
827
|
+
visibility + one-click removal of the header filters. */}
|
|
828
|
+
{activeFilterChips.length > 0 && (
|
|
829
|
+
<div className='pt-2'>
|
|
830
|
+
<FilterChipsRow
|
|
831
|
+
fields={activeFilterChips}
|
|
832
|
+
onClearAll={clearAllDynamicFilters}
|
|
833
|
+
data-testid='table-filter-chips'
|
|
834
|
+
/>
|
|
835
|
+
</div>
|
|
836
|
+
)}
|
|
774
837
|
</div>
|
|
775
838
|
{/* Desktop: classic horizontal-scroll table. Hidden on phones —
|
|
776
839
|
a 7-column table forces a wide horizontal scroll there, so we
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Shared filter presentation: the removable active-filter chip row + the pure
|
|
2
|
+
// helpers that summarize a filter's value and resolve its color. Used by BOTH
|
|
3
|
+
// DynamicKanban and DynamicTable so the two surfaces render identical chips
|
|
4
|
+
// (one component, no drift).
|
|
5
|
+
import { useTranslation } from 'react-i18next'
|
|
6
|
+
import { X } from 'lucide-react'
|
|
7
|
+
import { Badge, Button } from '@asteby/metacore-ui/primitives'
|
|
8
|
+
import { resolveColorCss } from '@asteby/metacore-ui/lib'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Human-readable summary of a field's selected filter values for a chip / row.
|
|
12
|
+
* Resolves option labels, unwraps the wire operators
|
|
13
|
+
* (`IN:`/`ILIKE:`/`RANGE:`/`GTE:`/`LTE:`/date `from_to`), and caps the list at
|
|
14
|
+
* `maxShown` values with a `+n` overflow. Pure — exported for unit tests.
|
|
15
|
+
*/
|
|
16
|
+
export function summarizeFilterValues(
|
|
17
|
+
values: string[] | undefined,
|
|
18
|
+
options: { label: string; value: string }[] | undefined,
|
|
19
|
+
maxShown = 2,
|
|
20
|
+
): string {
|
|
21
|
+
if (!values || values.length === 0) return ''
|
|
22
|
+
const opts = options ?? []
|
|
23
|
+
const labelFor = (v: string) => opts.find((o) => o.value === v)?.label ?? v
|
|
24
|
+
const first = values[0]
|
|
25
|
+
if (values.length === 1) {
|
|
26
|
+
if (first.startsWith('ILIKE:')) return `"${first.slice(6)}"`
|
|
27
|
+
if (first.startsWith('IN:')) {
|
|
28
|
+
return summarizeList(first.slice(3).split(','), labelFor, maxShown)
|
|
29
|
+
}
|
|
30
|
+
if (first.startsWith('RANGE:')) {
|
|
31
|
+
const [min, max] = first.slice(6).split(',')
|
|
32
|
+
return `${min || '…'} – ${max || '…'}`
|
|
33
|
+
}
|
|
34
|
+
if (/^\d{4}-\d{2}-\d{2}_/.test(first)) return first.replace('_', ' – ')
|
|
35
|
+
}
|
|
36
|
+
if (first.startsWith('GTE:') || first.startsWith('LTE:')) {
|
|
37
|
+
const min = values.find((v) => v.startsWith('GTE:'))?.slice(4) ?? ''
|
|
38
|
+
const max = values.find((v) => v.startsWith('LTE:'))?.slice(4) ?? ''
|
|
39
|
+
return `${min || '…'} – ${max || '…'}`
|
|
40
|
+
}
|
|
41
|
+
return summarizeList(values, labelFor, maxShown)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function summarizeList(
|
|
45
|
+
items: string[],
|
|
46
|
+
labelFor: (v: string) => string,
|
|
47
|
+
maxShown: number,
|
|
48
|
+
): string {
|
|
49
|
+
const labels = items.map(labelFor)
|
|
50
|
+
if (labels.length <= maxShown) return labels.join(', ')
|
|
51
|
+
return `${labels.slice(0, maxShown).join(', ')} +${labels.length - maxShown}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The color of a filter's first selected value (for the chip's dot) — e.g. a
|
|
56
|
+
* stage's palette color. Returns a resolved CSS color, or undefined for
|
|
57
|
+
* operator/range/free-text values that carry no option color.
|
|
58
|
+
*/
|
|
59
|
+
export function chipValueColor(config: {
|
|
60
|
+
selectedValues: string[]
|
|
61
|
+
options: { value: string; color?: string }[]
|
|
62
|
+
}): string | undefined {
|
|
63
|
+
const sel = config.selectedValues
|
|
64
|
+
if (!sel || sel.length === 0) return undefined
|
|
65
|
+
const first = sel[0]
|
|
66
|
+
let value = first
|
|
67
|
+
if (first.startsWith('IN:')) value = first.slice(3).split(',')[0]
|
|
68
|
+
else if (/^(ILIKE|RANGE|GTE|LTE):/.test(first)) return undefined
|
|
69
|
+
else if (/^\d{4}-\d{2}-\d{2}_/.test(first)) return undefined
|
|
70
|
+
const opt = config.options.find((o) => o.value === value)
|
|
71
|
+
return opt?.color ? resolveColorCss(opt.color) : undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Translates option labels through the app translator (manifest i18n keys →
|
|
76
|
+
* localized text). A raw value with no matching key falls through to itself via
|
|
77
|
+
* `defaultValue`. Pure — exported for unit tests.
|
|
78
|
+
*/
|
|
79
|
+
export function translateOptionLabels<T extends { label: string }>(
|
|
80
|
+
options: T[],
|
|
81
|
+
translate: (key: string) => string,
|
|
82
|
+
): T[] {
|
|
83
|
+
return options.map((o) => ({ ...o, label: translate(o.label) }))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface FilterChipField {
|
|
87
|
+
key: string
|
|
88
|
+
label: string
|
|
89
|
+
config: {
|
|
90
|
+
selectedValues: string[]
|
|
91
|
+
options: { label: string; value: string; color?: string }[]
|
|
92
|
+
filterKey: string
|
|
93
|
+
onFilterChange: (filterKey: string, values: string[]) => void
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface FilterChipsRowProps {
|
|
98
|
+
/** Fields with an active selection (one chip each). */
|
|
99
|
+
fields: FilterChipField[]
|
|
100
|
+
/** Clears every filter (the trailing "Limpiar todo"). */
|
|
101
|
+
onClearAll: () => void
|
|
102
|
+
className?: string
|
|
103
|
+
'data-testid'?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The removable active-filter chip row shown under a board/table toolbar. Each
|
|
108
|
+
* chip: an optional value-color dot, "Campo: valor(es)" (capped + `+n`), and an
|
|
109
|
+
* X that clears that field; a trailing "Limpiar todo". Renders nothing when no
|
|
110
|
+
* field is active.
|
|
111
|
+
*/
|
|
112
|
+
export function FilterChipsRow({
|
|
113
|
+
fields,
|
|
114
|
+
onClearAll,
|
|
115
|
+
className,
|
|
116
|
+
'data-testid': testId,
|
|
117
|
+
}: FilterChipsRowProps) {
|
|
118
|
+
const { t } = useTranslation()
|
|
119
|
+
if (fields.length === 0) return null
|
|
120
|
+
return (
|
|
121
|
+
<div
|
|
122
|
+
className={`flex flex-wrap items-center gap-1.5${className ? ` ${className}` : ''}`}
|
|
123
|
+
data-testid={testId}
|
|
124
|
+
>
|
|
125
|
+
{fields.map((field) => {
|
|
126
|
+
const summary = summarizeFilterValues(
|
|
127
|
+
field.config.selectedValues,
|
|
128
|
+
field.config.options,
|
|
129
|
+
)
|
|
130
|
+
const dot = chipValueColor(field.config)
|
|
131
|
+
return (
|
|
132
|
+
<Badge
|
|
133
|
+
key={field.key}
|
|
134
|
+
variant="secondary"
|
|
135
|
+
className="h-6 gap-1.5 rounded-md pl-2 pr-1 text-xs font-normal"
|
|
136
|
+
>
|
|
137
|
+
{dot && (
|
|
138
|
+
<span
|
|
139
|
+
className="size-2 shrink-0 rounded-full"
|
|
140
|
+
style={{ backgroundColor: dot }}
|
|
141
|
+
/>
|
|
142
|
+
)}
|
|
143
|
+
<span className="font-medium">{field.label}:</span>
|
|
144
|
+
<span className="max-w-[180px] truncate text-muted-foreground">
|
|
145
|
+
{summary}
|
|
146
|
+
</span>
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
onClick={() =>
|
|
150
|
+
field.config.onFilterChange(
|
|
151
|
+
field.config.filterKey,
|
|
152
|
+
[],
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
className="ml-0.5 rounded-sm p-0.5 transition-colors hover:bg-muted-foreground/20"
|
|
156
|
+
aria-label={t('filters.removeFilter', {
|
|
157
|
+
defaultValue: 'Quitar filtro',
|
|
158
|
+
})}
|
|
159
|
+
>
|
|
160
|
+
<X className="h-3 w-3" />
|
|
161
|
+
</button>
|
|
162
|
+
</Badge>
|
|
163
|
+
)
|
|
164
|
+
})}
|
|
165
|
+
<Button
|
|
166
|
+
variant="ghost"
|
|
167
|
+
size="sm"
|
|
168
|
+
className="h-6 gap-1 px-2 text-xs text-muted-foreground"
|
|
169
|
+
onClick={onClearAll}
|
|
170
|
+
>
|
|
171
|
+
{t('filters.clearAll', { defaultValue: 'Limpiar todo' })}
|
|
172
|
+
</Button>
|
|
173
|
+
</div>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
@@ -96,7 +96,8 @@ export function useDynamicFilters(
|
|
|
96
96
|
: model
|
|
97
97
|
? `/data/${model}/facets`
|
|
98
98
|
: null)
|
|
99
|
-
const getFacetLoader =
|
|
99
|
+
const { getFacetLoader, prefetchFacets, facetOptions } =
|
|
100
|
+
useFacetLoaders(facetsBase)
|
|
100
101
|
|
|
101
102
|
// Prefetch the option lists for relation/select filters (once per model). The
|
|
102
103
|
// combobox needs these before the user opens it; mirrors DynamicTable's
|
|
@@ -227,6 +228,9 @@ export function useDynamicFilters(
|
|
|
227
228
|
if (loader) {
|
|
228
229
|
fType = 'facet'
|
|
229
230
|
loadOptions = loader
|
|
231
|
+
// Prewarmed values (from prefetchFacets) → the popover opens with the
|
|
232
|
+
// list already there, no "Cargando…" flash.
|
|
233
|
+
options = facetOptions.get(f.column || f.key) ?? []
|
|
230
234
|
}
|
|
231
235
|
}
|
|
232
236
|
if (fType === 'select' && options.length === 0 && !f.searchEndpoint) continue
|
|
@@ -285,6 +289,8 @@ export function useDynamicFilters(
|
|
|
285
289
|
if (loader) {
|
|
286
290
|
filterType = 'facet'
|
|
287
291
|
loadOptions = loader
|
|
292
|
+
// Prewarmed values (from prefetchFacets) → instant open.
|
|
293
|
+
options = facetOptions.get(c.key) ?? []
|
|
288
294
|
}
|
|
289
295
|
}
|
|
290
296
|
|
|
@@ -307,8 +313,27 @@ export function useDynamicFilters(
|
|
|
307
313
|
handleDynamicFilterChange,
|
|
308
314
|
facetsBase,
|
|
309
315
|
getFacetLoader,
|
|
316
|
+
facetOptions,
|
|
310
317
|
])
|
|
311
318
|
|
|
319
|
+
// Prewarm every facet field in one burst once the configs settle, so the
|
|
320
|
+
// Sheet popover and the lane value-picker open instantly (values + counts)
|
|
321
|
+
// instead of flashing "Cargando…". Deduped inside prefetchFacets by field set.
|
|
322
|
+
const facetFieldsSig = useMemo(() => {
|
|
323
|
+
const keys: string[] = []
|
|
324
|
+
// Key on `filterKey` (the column the loader queries), not the config map key
|
|
325
|
+
// — they differ when an explicit filter sets a distinct `column`.
|
|
326
|
+
for (const config of columnFilterConfigs.values()) {
|
|
327
|
+
if (config.filterType === 'facet') keys.push(config.filterKey)
|
|
328
|
+
}
|
|
329
|
+
return keys.join('|')
|
|
330
|
+
}, [columnFilterConfigs])
|
|
331
|
+
|
|
332
|
+
useEffect(() => {
|
|
333
|
+
if (!facetFieldsSig) return
|
|
334
|
+
prefetchFacets(facetFieldsSig.split('|'))
|
|
335
|
+
}, [facetFieldsSig, prefetchFacets])
|
|
336
|
+
|
|
312
337
|
const searchableKeys = useMemo(
|
|
313
338
|
() => (metadata ? getSearchableColumnKeys(metadata) : null),
|
|
314
339
|
[metadata],
|
package/src/use-facet-loaders.ts
CHANGED
|
@@ -4,10 +4,30 @@
|
|
|
4
4
|
// per-field option loader + result cache lives here to guarantee they facet
|
|
5
5
|
// identically (one board and its table sibling hit the same `/facets` endpoint
|
|
6
6
|
// with the same caching).
|
|
7
|
-
import { useCallback, useEffect, useRef } from 'react'
|
|
7
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
8
8
|
import { useApi } from './api-context'
|
|
9
9
|
import type { FilterOption } from './dynamic-columns-shim'
|
|
10
10
|
|
|
11
|
+
export interface UseFacetLoadersResult {
|
|
12
|
+
/**
|
|
13
|
+
* Stable per-field loader → `<facetsBase>?field=&q=&limit=`. Cached per
|
|
14
|
+
* `field+query`. Null `facetsBase` → yields `undefined` (column stays text).
|
|
15
|
+
*/
|
|
16
|
+
getFacetLoader: (
|
|
17
|
+
field: string,
|
|
18
|
+
) => ((q?: string) => Promise<FilterOption[]>) | undefined
|
|
19
|
+
/**
|
|
20
|
+
* Warms the caches for a set of facet fields in ONE parallel burst (called
|
|
21
|
+
* when metadata resolves), so a popover opens instantly with values + counts
|
|
22
|
+
* instead of showing "Cargando…". Deduped by the field set; a field whose
|
|
23
|
+
* request fails is simply omitted (it degrades to lazy/text — the rest are
|
|
24
|
+
* unaffected). Resolved options also land in `facetOptions`.
|
|
25
|
+
*/
|
|
26
|
+
prefetchFacets: (fields: string[]) => void
|
|
27
|
+
/** Prefetched options per field (empty until `prefetchFacets` resolves). */
|
|
28
|
+
facetOptions: Map<string, FilterOption[]>
|
|
29
|
+
}
|
|
30
|
+
|
|
11
31
|
/**
|
|
12
32
|
* Long-text / body columns aren't worth faceting (thousands of unique values,
|
|
13
33
|
* each a paragraph). Heuristic on the metadata, not a hardcoded name list:
|
|
@@ -29,26 +49,39 @@ export function isLongTextColumn(c: {
|
|
|
29
49
|
}
|
|
30
50
|
|
|
31
51
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* when `facetsBase` changes (model switch).
|
|
37
|
-
* factory that yields `undefined` — the caller keeps the column as plain text.
|
|
52
|
+
* Facet option machinery: a stable per-field lazy loader (`getFacetLoader`, with
|
|
53
|
+
* a `field+query` result cache) plus `prefetchFacets` to warm every facet
|
|
54
|
+
* field's values up front and `facetOptions` holding those prewarmed results.
|
|
55
|
+
* Loader identity is memoized per field (so it doesn't churn the config memo
|
|
56
|
+
* that depends on it). Caches reset when `facetsBase` changes (model switch).
|
|
38
57
|
*/
|
|
39
|
-
export function useFacetLoaders(facetsBase: string | null) {
|
|
58
|
+
export function useFacetLoaders(facetsBase: string | null): UseFacetLoadersResult {
|
|
40
59
|
const api = useApi()
|
|
41
60
|
const loaderCache = useRef<
|
|
42
61
|
Map<string, (q?: string) => Promise<FilterOption[]>>
|
|
43
62
|
>(new Map())
|
|
44
63
|
const resultCache = useRef<Map<string, FilterOption[]>>(new Map())
|
|
64
|
+
const prefetchSig = useRef<string | null>(null)
|
|
65
|
+
const mountedRef = useRef(true)
|
|
66
|
+
const [facetOptions, setFacetOptions] = useState<Map<string, FilterOption[]>>(
|
|
67
|
+
new Map(),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
mountedRef.current = true
|
|
72
|
+
return () => {
|
|
73
|
+
mountedRef.current = false
|
|
74
|
+
}
|
|
75
|
+
}, [])
|
|
45
76
|
|
|
46
77
|
useEffect(() => {
|
|
47
78
|
loaderCache.current.clear()
|
|
48
79
|
resultCache.current.clear()
|
|
80
|
+
prefetchSig.current = null
|
|
81
|
+
setFacetOptions(new Map())
|
|
49
82
|
}, [facetsBase])
|
|
50
83
|
|
|
51
|
-
|
|
84
|
+
const getFacetLoader = useCallback(
|
|
52
85
|
(field: string) => {
|
|
53
86
|
if (!facetsBase) return undefined
|
|
54
87
|
const existing = loaderCache.current.get(field)
|
|
@@ -76,4 +109,38 @@ export function useFacetLoaders(facetsBase: string | null) {
|
|
|
76
109
|
},
|
|
77
110
|
[api, facetsBase],
|
|
78
111
|
)
|
|
112
|
+
|
|
113
|
+
const prefetchFacets = useCallback(
|
|
114
|
+
(fields: string[]) => {
|
|
115
|
+
if (!facetsBase || fields.length === 0) return
|
|
116
|
+
const sig = [...fields].sort().join('|')
|
|
117
|
+
if (prefetchSig.current === sig) return
|
|
118
|
+
prefetchSig.current = sig
|
|
119
|
+
// One parallel burst; the loader seeds its own `${field}::` cache so the
|
|
120
|
+
// subsequent lazy open (q === '') is a cache hit. allSettled so one bad
|
|
121
|
+
// field never blocks the others.
|
|
122
|
+
void Promise.allSettled(
|
|
123
|
+
fields.map(async (field) => {
|
|
124
|
+
const loader = getFacetLoader(field)
|
|
125
|
+
if (!loader) return null
|
|
126
|
+
const opts = await loader()
|
|
127
|
+
return { field, opts }
|
|
128
|
+
}),
|
|
129
|
+
).then((results) => {
|
|
130
|
+
if (!mountedRef.current) return
|
|
131
|
+
setFacetOptions((prev) => {
|
|
132
|
+
const next = new Map(prev)
|
|
133
|
+
for (const r of results) {
|
|
134
|
+
if (r.status === 'fulfilled' && r.value) {
|
|
135
|
+
next.set(r.value.field, r.value.opts)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return next
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
},
|
|
142
|
+
[facetsBase, getFacetLoader],
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return { getFacetLoader, prefetchFacets, facetOptions }
|
|
79
146
|
}
|