@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.
@@ -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
- const fType = f.type as ColumnFilterConfig['filterType']
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 (c.filterType) filterType = c.filterType
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
- const options = hasStaticOptions
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
- const fType = f.type as ColumnFilterConfig['filterType']
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 (c.filterType) filterType = c.filterType
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
- const options = hasStaticOptions
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
- }, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange])
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
+ }