@asteby/metacore-runtime-react 21.1.0 → 23.0.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 +79 -0
- package/dist/dynamic-columns-shim.d.ts +9 -1
- package/dist/dynamic-columns-shim.d.ts.map +1 -1
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +1 -0
- package/dist/dynamic-kanban.d.ts +35 -0
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +279 -36
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +51 -4
- package/dist/use-dynamic-filters.d.ts +19 -0
- package/dist/use-dynamic-filters.d.ts.map +1 -1
- package/dist/use-dynamic-filters.js +99 -5
- package/dist/use-facet-loaders.d.ts +38 -0
- package/dist/use-facet-loaders.d.ts.map +1 -0
- package/dist/use-facet-loaders.js +108 -0
- package/package.json +4 -4
- package/src/__tests__/dynamic-kanban.test.tsx +197 -0
- package/src/__tests__/use-dynamic-filters.test.tsx +243 -0
- package/src/dynamic-columns-shim.ts +9 -1
- package/src/dynamic-columns.tsx +1 -0
- package/src/dynamic-kanban.tsx +607 -100
- package/src/dynamic-table.tsx +52 -4
- package/src/use-dynamic-filters.ts +125 -5
- package/src/use-facet-loaders.ts +146 -0
package/src/dynamic-table.tsx
CHANGED
|
@@ -66,6 +66,7 @@ import { useMetadataCache } from './metadata-cache'
|
|
|
66
66
|
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
|
+
import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders'
|
|
69
70
|
import { OptionsContext } from './options-context'
|
|
70
71
|
import type { TableMetadata, ApiResponse } from './types'
|
|
71
72
|
import { getSearchableColumnKeys } from './column-visibility'
|
|
@@ -530,15 +531,28 @@ export function DynamicTable({
|
|
|
530
531
|
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }))
|
|
531
532
|
}, [])
|
|
532
533
|
|
|
534
|
+
// Same facet loader machinery the board uses, so a text column filters
|
|
535
|
+
// identically in the table header and the kanban Sheet (the host's
|
|
536
|
+
// getDynamicColumns forwards `loadOptions` into the column meta). Facets base
|
|
537
|
+
// derived off the list endpoint exactly like the aggregate endpoint below.
|
|
538
|
+
const facetsBase = endpoint ? `${endpoint}/facets` : model ? `/data/${model}/facets` : null
|
|
539
|
+
const { getFacetLoader } = useFacetLoaders(facetsBase)
|
|
540
|
+
|
|
533
541
|
const columnFilterConfigs = useMemo(() => {
|
|
534
542
|
const map = new Map<string, ColumnFilterConfig>()
|
|
535
543
|
if (!metadata) return map
|
|
544
|
+
const stageOptions = (metadata.stages ?? []).map((s) => ({
|
|
545
|
+
label: s.label,
|
|
546
|
+
value: s.key,
|
|
547
|
+
color: s.color,
|
|
548
|
+
}))
|
|
549
|
+
const groupBy = metadata.group_by
|
|
536
550
|
// Explicit `metadata.filters` wins. When the backend does not emit
|
|
537
551
|
// them, derive a filter chip from every column flagged
|
|
538
552
|
// `filterable: true` — keeps the kernel API minimal (one flag on the
|
|
539
553
|
// column) while still rendering the FilterableColumnHeader.
|
|
540
554
|
for (const f of metadata.filters ?? []) {
|
|
541
|
-
|
|
555
|
+
let fType = f.type as ColumnFilterConfig['filterType']
|
|
542
556
|
let options: { label: string; value: string; icon?: string; color?: string }[] = []
|
|
543
557
|
if (f.options && f.options.length > 0) {
|
|
544
558
|
options = f.options.map(o => ({ label: o.label, value: String(o.value), icon: o.icon, color: o.color }))
|
|
@@ -546,6 +560,22 @@ export function DynamicTable({
|
|
|
546
560
|
if (f.searchEndpoint && filterOptionsMap.has(f.searchEndpoint)) {
|
|
547
561
|
options = filterOptionsMap.get(f.searchEndpoint) || []
|
|
548
562
|
}
|
|
563
|
+
// (A) A stage column with no options of its own inherits the
|
|
564
|
+
// pipeline stages (with their colors) as a real select.
|
|
565
|
+
if (options.length === 0 && !f.searchEndpoint && (f.column || f.key) === groupBy && stageOptions.length > 0) {
|
|
566
|
+
fType = 'select'
|
|
567
|
+
options = stageOptions
|
|
568
|
+
}
|
|
569
|
+
// (B) A plain text filter becomes a facet value-picker when a facets
|
|
570
|
+
// endpoint is available (degrades to "Contiene..." if it yields nothing).
|
|
571
|
+
let loadOptions: ColumnFilterConfig['loadOptions']
|
|
572
|
+
if (fType === 'text' && facetsBase) {
|
|
573
|
+
const loader = getFacetLoader(f.column || f.key)
|
|
574
|
+
if (loader) {
|
|
575
|
+
fType = 'facet'
|
|
576
|
+
loadOptions = loader
|
|
577
|
+
}
|
|
578
|
+
}
|
|
549
579
|
if (fType === 'select' && options.length === 0 && !f.searchEndpoint) continue
|
|
550
580
|
map.set(f.key, {
|
|
551
581
|
filterType: fType,
|
|
@@ -555,6 +585,7 @@ export function DynamicTable({
|
|
|
555
585
|
onFilterChange: handleDynamicFilterChange,
|
|
556
586
|
loading: f.searchEndpoint ? !filterOptionsMap.has(f.searchEndpoint) : false,
|
|
557
587
|
searchEndpoint: f.searchEndpoint,
|
|
588
|
+
loadOptions,
|
|
558
589
|
})
|
|
559
590
|
}
|
|
560
591
|
for (const c of metadata.columns ?? []) {
|
|
@@ -571,8 +602,11 @@ export function DynamicTable({
|
|
|
571
602
|
// - number / number_range / numeric → number range
|
|
572
603
|
// - date → date range picker (start/end calendar)
|
|
573
604
|
// - everything else (text, email, phone, tags…) → text contains
|
|
605
|
+
// (A) Stage column with no options of its own → colored select.
|
|
606
|
+
const isStageColumn = c.key === groupBy && !hasStaticOptions && !hasEndpoint && !c.filterType && stageOptions.length > 0
|
|
574
607
|
let filterType: ColumnFilterConfig['filterType']
|
|
575
|
-
if (
|
|
608
|
+
if (isStageColumn) filterType = 'select'
|
|
609
|
+
else if (c.filterType) filterType = c.filterType
|
|
576
610
|
else if (isRelation && hasEndpoint) filterType = 'dynamic_select'
|
|
577
611
|
else if (hasStaticOptions || hasEndpoint) filterType = 'select'
|
|
578
612
|
else if (c.type === 'boolean') filterType = 'boolean'
|
|
@@ -581,7 +615,7 @@ export function DynamicTable({
|
|
|
581
615
|
filterType = 'date_range'
|
|
582
616
|
else filterType = 'text'
|
|
583
617
|
|
|
584
|
-
|
|
618
|
+
let options = hasStaticOptions
|
|
585
619
|
? c.options!.map(o => ({
|
|
586
620
|
label: o.label,
|
|
587
621
|
value: String(o.value),
|
|
@@ -591,6 +625,19 @@ export function DynamicTable({
|
|
|
591
625
|
: hasEndpoint && filterOptionsMap.has(c.searchEndpoint!)
|
|
592
626
|
? filterOptionsMap.get(c.searchEndpoint!) || []
|
|
593
627
|
: []
|
|
628
|
+
if (isStageColumn) options = stageOptions
|
|
629
|
+
|
|
630
|
+
// (B) Upgrade a plain text filter to a facet value-picker unless it's
|
|
631
|
+
// a long-text/body column (too many unique values to enumerate).
|
|
632
|
+
let loadOptions: ColumnFilterConfig['loadOptions']
|
|
633
|
+
if (filterType === 'text' && facetsBase && !isLongTextColumn(c)) {
|
|
634
|
+
const loader = getFacetLoader(c.key)
|
|
635
|
+
if (loader) {
|
|
636
|
+
filterType = 'facet'
|
|
637
|
+
loadOptions = loader
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
594
641
|
map.set(c.key, {
|
|
595
642
|
filterType,
|
|
596
643
|
filterKey: c.key,
|
|
@@ -599,10 +646,11 @@ export function DynamicTable({
|
|
|
599
646
|
onFilterChange: handleDynamicFilterChange,
|
|
600
647
|
loading: hasEndpoint && !filterOptionsMap.has(c.searchEndpoint!),
|
|
601
648
|
searchEndpoint: c.searchEndpoint,
|
|
649
|
+
loadOptions,
|
|
602
650
|
})
|
|
603
651
|
}
|
|
604
652
|
return map
|
|
605
|
-
}, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange])
|
|
653
|
+
}, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange, facetsBase, getFacetLoader])
|
|
606
654
|
|
|
607
655
|
const columns = useMemo(() => {
|
|
608
656
|
if (!viewMetadata) return []
|
|
@@ -14,6 +14,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
14
14
|
import { useApi } from './api-context'
|
|
15
15
|
import { DATE_CELL_TYPES } from './dynamic-columns'
|
|
16
16
|
import { getSearchableColumnKeys } from './column-visibility'
|
|
17
|
+
import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders'
|
|
17
18
|
import type { ColumnFilterConfig, FilterOption } from './dynamic-columns-shim'
|
|
18
19
|
import type { TableMetadata } from './types'
|
|
19
20
|
|
|
@@ -23,6 +24,25 @@ export interface UseDynamicFiltersOptions {
|
|
|
23
24
|
* Mirrors DynamicTable's `defaultFilters` — e.g. scoping a board to one owner.
|
|
24
25
|
*/
|
|
25
26
|
defaultFilters?: Record<string, any>
|
|
27
|
+
/**
|
|
28
|
+
* Model key — the fallback base for the per-field `facets` endpoint when no
|
|
29
|
+
* `endpoint` is given. Omit all of `endpoint`/`model`/`facetsEndpoint` to keep
|
|
30
|
+
* text filters as bare "Contiene..." boxes.
|
|
31
|
+
*/
|
|
32
|
+
model?: string
|
|
33
|
+
/**
|
|
34
|
+
* The org-scoped LIST endpoint (e.g. `/data/<model>/me`). The facets base is
|
|
35
|
+
* derived from it EXACTLY like DynamicTable derives the aggregate endpoint —
|
|
36
|
+
* `<endpoint>/facets` — so the board and its table sibling hit the same route
|
|
37
|
+
* (the backend registers both `/data/:model/facets` and
|
|
38
|
+
* `/data/:model/me/facets`).
|
|
39
|
+
*/
|
|
40
|
+
endpoint?: string
|
|
41
|
+
/**
|
|
42
|
+
* Explicit `facets` endpoint override — wins over `endpoint`/`model`. The
|
|
43
|
+
* loader appends `?field=<key>&q=<text>&limit=50`.
|
|
44
|
+
*/
|
|
45
|
+
facetsEndpoint?: string
|
|
26
46
|
}
|
|
27
47
|
|
|
28
48
|
export interface UseDynamicFiltersResult {
|
|
@@ -57,7 +77,7 @@ export function useDynamicFilters(
|
|
|
57
77
|
metadata: TableMetadata | null,
|
|
58
78
|
opts: UseDynamicFiltersOptions = {},
|
|
59
79
|
): UseDynamicFiltersResult {
|
|
60
|
-
const { defaultFilters } = opts
|
|
80
|
+
const { defaultFilters, model, endpoint, facetsEndpoint } = opts
|
|
61
81
|
const api = useApi()
|
|
62
82
|
|
|
63
83
|
const [dynamicFilters, setDynamicFilters] = useState<Record<string, string[]>>({})
|
|
@@ -66,6 +86,19 @@ export function useDynamicFilters(
|
|
|
66
86
|
Map<string, FilterOption[]>
|
|
67
87
|
>(new Map())
|
|
68
88
|
|
|
89
|
+
// Where a `facet` filter loads its distinct values from. Explicit override
|
|
90
|
+
// wins; otherwise `<endpoint>/facets` (same derivation as the aggregate
|
|
91
|
+
// endpoint), falling back to the model. Null → text filters stay plain.
|
|
92
|
+
const facetsBase =
|
|
93
|
+
facetsEndpoint ??
|
|
94
|
+
(endpoint
|
|
95
|
+
? `${endpoint}/facets`
|
|
96
|
+
: model
|
|
97
|
+
? `/data/${model}/facets`
|
|
98
|
+
: null)
|
|
99
|
+
const { getFacetLoader, prefetchFacets, facetOptions } =
|
|
100
|
+
useFacetLoaders(facetsBase)
|
|
101
|
+
|
|
69
102
|
// Prefetch the option lists for relation/select filters (once per model). The
|
|
70
103
|
// combobox needs these before the user opens it; mirrors DynamicTable's
|
|
71
104
|
// metadata-init option prefetch, narrowed to the FILTER endpoints only (the
|
|
@@ -151,8 +184,18 @@ export function useDynamicFilters(
|
|
|
151
184
|
const columnFilterConfigs = useMemo(() => {
|
|
152
185
|
const map = new Map<string, ColumnFilterConfig>()
|
|
153
186
|
if (!metadata) return map
|
|
187
|
+
const stages = metadata.stages ?? []
|
|
188
|
+
const groupBy = metadata.group_by
|
|
189
|
+
// Stage options derived once from the pipeline machine, reused wherever a
|
|
190
|
+
// stage column has no inline options of its own (A).
|
|
191
|
+
const stageOptions: FilterOption[] = stages.map((s) => ({
|
|
192
|
+
label: s.label,
|
|
193
|
+
value: s.key,
|
|
194
|
+
color: s.color,
|
|
195
|
+
}))
|
|
196
|
+
|
|
154
197
|
for (const f of metadata.filters ?? []) {
|
|
155
|
-
|
|
198
|
+
let fType = f.type as ColumnFilterConfig['filterType']
|
|
156
199
|
let options: FilterOption[] = []
|
|
157
200
|
if (f.options && f.options.length > 0) {
|
|
158
201
|
options = f.options.map((o) => ({
|
|
@@ -165,6 +208,31 @@ export function useDynamicFilters(
|
|
|
165
208
|
if (f.searchEndpoint && filterOptionsMap.has(f.searchEndpoint)) {
|
|
166
209
|
options = filterOptionsMap.get(f.searchEndpoint) || []
|
|
167
210
|
}
|
|
211
|
+
// (A) Stage column with no options of its own → project the pipeline
|
|
212
|
+
// stages (with their colors) into a real select instead of leaving it as
|
|
213
|
+
// a text box.
|
|
214
|
+
if (
|
|
215
|
+
options.length === 0 &&
|
|
216
|
+
!f.searchEndpoint &&
|
|
217
|
+
(f.column || f.key) === groupBy &&
|
|
218
|
+
stageOptions.length > 0
|
|
219
|
+
) {
|
|
220
|
+
fType = 'select'
|
|
221
|
+
options = stageOptions
|
|
222
|
+
}
|
|
223
|
+
// (B) A plain text filter becomes a facet value-picker when a facets
|
|
224
|
+
// endpoint is available.
|
|
225
|
+
let loadOptions: ColumnFilterConfig['loadOptions']
|
|
226
|
+
if (fType === 'text' && facetsBase) {
|
|
227
|
+
const loader = getFacetLoader(f.column || f.key)
|
|
228
|
+
if (loader) {
|
|
229
|
+
fType = 'facet'
|
|
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) ?? []
|
|
234
|
+
}
|
|
235
|
+
}
|
|
168
236
|
if (fType === 'select' && options.length === 0 && !f.searchEndpoint) continue
|
|
169
237
|
map.set(f.key, {
|
|
170
238
|
filterType: fType,
|
|
@@ -174,6 +242,7 @@ export function useDynamicFilters(
|
|
|
174
242
|
onFilterChange: handleDynamicFilterChange,
|
|
175
243
|
loading: f.searchEndpoint ? !filterOptionsMap.has(f.searchEndpoint) : false,
|
|
176
244
|
searchEndpoint: f.searchEndpoint,
|
|
245
|
+
loadOptions,
|
|
177
246
|
})
|
|
178
247
|
}
|
|
179
248
|
for (const c of metadata.columns ?? []) {
|
|
@@ -181,8 +250,17 @@ export function useDynamicFilters(
|
|
|
181
250
|
const hasStaticOptions = (c.options?.length ?? 0) > 0
|
|
182
251
|
const hasEndpoint = !!c.searchEndpoint
|
|
183
252
|
const isRelation = !!c.ref || c.filterType === 'dynamic_select'
|
|
253
|
+
// (A) A stage column with no options of its own inherits the pipeline's
|
|
254
|
+
// stages — so "Stage" becomes a colored select, not a text box.
|
|
255
|
+
const isStageColumn =
|
|
256
|
+
c.key === groupBy &&
|
|
257
|
+
!hasStaticOptions &&
|
|
258
|
+
!hasEndpoint &&
|
|
259
|
+
!c.filterType &&
|
|
260
|
+
stageOptions.length > 0
|
|
184
261
|
let filterType: ColumnFilterConfig['filterType']
|
|
185
|
-
if (
|
|
262
|
+
if (isStageColumn) filterType = 'select'
|
|
263
|
+
else if (c.filterType) filterType = c.filterType
|
|
186
264
|
else if (isRelation && hasEndpoint) filterType = 'dynamic_select'
|
|
187
265
|
else if (hasStaticOptions || hasEndpoint) filterType = 'select'
|
|
188
266
|
else if (c.type === 'boolean') filterType = 'boolean'
|
|
@@ -191,7 +269,7 @@ export function useDynamicFilters(
|
|
|
191
269
|
filterType = 'date_range'
|
|
192
270
|
else filterType = 'text'
|
|
193
271
|
|
|
194
|
-
|
|
272
|
+
let options = hasStaticOptions
|
|
195
273
|
? c.options!.map((o) => ({
|
|
196
274
|
label: o.label,
|
|
197
275
|
value: String(o.value),
|
|
@@ -201,6 +279,21 @@ export function useDynamicFilters(
|
|
|
201
279
|
: hasEndpoint && filterOptionsMap.has(c.searchEndpoint!)
|
|
202
280
|
? filterOptionsMap.get(c.searchEndpoint!) || []
|
|
203
281
|
: []
|
|
282
|
+
if (isStageColumn) options = stageOptions
|
|
283
|
+
|
|
284
|
+
// (B) Upgrade a plain text filter to a facet value-picker (unless it's a
|
|
285
|
+
// long-text/body column — too many unique values to enumerate).
|
|
286
|
+
let loadOptions: ColumnFilterConfig['loadOptions']
|
|
287
|
+
if (filterType === 'text' && facetsBase && !isLongTextColumn(c)) {
|
|
288
|
+
const loader = getFacetLoader(c.key)
|
|
289
|
+
if (loader) {
|
|
290
|
+
filterType = 'facet'
|
|
291
|
+
loadOptions = loader
|
|
292
|
+
// Prewarmed values (from prefetchFacets) → instant open.
|
|
293
|
+
options = facetOptions.get(c.key) ?? []
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
204
297
|
map.set(c.key, {
|
|
205
298
|
filterType,
|
|
206
299
|
filterKey: c.key,
|
|
@@ -209,10 +302,37 @@ export function useDynamicFilters(
|
|
|
209
302
|
onFilterChange: handleDynamicFilterChange,
|
|
210
303
|
loading: hasEndpoint && !filterOptionsMap.has(c.searchEndpoint!),
|
|
211
304
|
searchEndpoint: c.searchEndpoint,
|
|
305
|
+
loadOptions,
|
|
212
306
|
})
|
|
213
307
|
}
|
|
214
308
|
return map
|
|
215
|
-
}, [
|
|
309
|
+
}, [
|
|
310
|
+
metadata,
|
|
311
|
+
filterOptionsMap,
|
|
312
|
+
dynamicFilters,
|
|
313
|
+
handleDynamicFilterChange,
|
|
314
|
+
facetsBase,
|
|
315
|
+
getFacetLoader,
|
|
316
|
+
facetOptions,
|
|
317
|
+
])
|
|
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])
|
|
216
336
|
|
|
217
337
|
const searchableKeys = useMemo(
|
|
218
338
|
() => (metadata ? getSearchableColumnKeys(metadata) : null),
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// useFacetLoaders — the shared machinery that turns a plain text filter into a
|
|
2
|
+
// `facet` value-picker. Both DynamicKanban (via useDynamicFilters) and
|
|
3
|
+
// DynamicTable derive their filter configs independently, so the lazy
|
|
4
|
+
// per-field option loader + result cache lives here to guarantee they facet
|
|
5
|
+
// identically (one board and its table sibling hit the same `/facets` endpoint
|
|
6
|
+
// with the same caching).
|
|
7
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
8
|
+
import { useApi } from './api-context'
|
|
9
|
+
import type { FilterOption } from './dynamic-columns-shim'
|
|
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
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Long-text / body columns aren't worth faceting (thousands of unique values,
|
|
33
|
+
* each a paragraph). Heuristic on the metadata, not a hardcoded name list:
|
|
34
|
+
* a truncate-text/widget cell or a json/long-text SQL type are the strong
|
|
35
|
+
* signals; the name pattern is only a last-resort fallback.
|
|
36
|
+
*/
|
|
37
|
+
export function isLongTextColumn(c: {
|
|
38
|
+
key: string
|
|
39
|
+
type?: string
|
|
40
|
+
cellStyle?: string
|
|
41
|
+
}): boolean {
|
|
42
|
+
if (c.cellStyle === 'truncate-text' || c.cellStyle === 'widget') return true
|
|
43
|
+
const t = (c.type || '').toLowerCase()
|
|
44
|
+
if (t === 'json' || t === 'long_text' || t === 'text_long' || t === 'widget')
|
|
45
|
+
return true
|
|
46
|
+
return /^(body|description|content|notes?|comment|message|summary)$/i.test(
|
|
47
|
+
c.key,
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
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).
|
|
57
|
+
*/
|
|
58
|
+
export function useFacetLoaders(facetsBase: string | null): UseFacetLoadersResult {
|
|
59
|
+
const api = useApi()
|
|
60
|
+
const loaderCache = useRef<
|
|
61
|
+
Map<string, (q?: string) => Promise<FilterOption[]>>
|
|
62
|
+
>(new Map())
|
|
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
|
+
}, [])
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
loaderCache.current.clear()
|
|
79
|
+
resultCache.current.clear()
|
|
80
|
+
prefetchSig.current = null
|
|
81
|
+
setFacetOptions(new Map())
|
|
82
|
+
}, [facetsBase])
|
|
83
|
+
|
|
84
|
+
const getFacetLoader = useCallback(
|
|
85
|
+
(field: string) => {
|
|
86
|
+
if (!facetsBase) return undefined
|
|
87
|
+
const existing = loaderCache.current.get(field)
|
|
88
|
+
if (existing) return existing
|
|
89
|
+
const loader = async (q?: string): Promise<FilterOption[]> => {
|
|
90
|
+
const term = q?.trim() || ''
|
|
91
|
+
const cacheKey = `${field}::${term}`
|
|
92
|
+
const cached = resultCache.current.get(cacheKey)
|
|
93
|
+
if (cached) return cached
|
|
94
|
+
const res = await api.get(facetsBase, {
|
|
95
|
+
params: { field, q: term || undefined, limit: 50 },
|
|
96
|
+
})
|
|
97
|
+
const body = (res as { data: any }).data
|
|
98
|
+
const rows: any[] = body?.success ? body.data || [] : []
|
|
99
|
+
const opts: FilterOption[] = rows.map((r) => ({
|
|
100
|
+
value: String(r.value ?? ''),
|
|
101
|
+
label: String(r.label ?? r.value ?? ''),
|
|
102
|
+
count: typeof r.count === 'number' ? r.count : undefined,
|
|
103
|
+
}))
|
|
104
|
+
resultCache.current.set(cacheKey, opts)
|
|
105
|
+
return opts
|
|
106
|
+
}
|
|
107
|
+
loaderCache.current.set(field, loader)
|
|
108
|
+
return loader
|
|
109
|
+
},
|
|
110
|
+
[api, facetsBase],
|
|
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 }
|
|
146
|
+
}
|