@asteby/metacore-runtime-react 21.0.0 → 22.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,18 @@ 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 = useFacetLoaders(facetsBase)
100
+
69
101
  // Prefetch the option lists for relation/select filters (once per model). The
70
102
  // combobox needs these before the user opens it; mirrors DynamicTable's
71
103
  // metadata-init option prefetch, narrowed to the FILTER endpoints only (the
@@ -151,8 +183,18 @@ export function useDynamicFilters(
151
183
  const columnFilterConfigs = useMemo(() => {
152
184
  const map = new Map<string, ColumnFilterConfig>()
153
185
  if (!metadata) return map
186
+ const stages = metadata.stages ?? []
187
+ const groupBy = metadata.group_by
188
+ // Stage options derived once from the pipeline machine, reused wherever a
189
+ // stage column has no inline options of its own (A).
190
+ const stageOptions: FilterOption[] = stages.map((s) => ({
191
+ label: s.label,
192
+ value: s.key,
193
+ color: s.color,
194
+ }))
195
+
154
196
  for (const f of metadata.filters ?? []) {
155
- const fType = f.type as ColumnFilterConfig['filterType']
197
+ let fType = f.type as ColumnFilterConfig['filterType']
156
198
  let options: FilterOption[] = []
157
199
  if (f.options && f.options.length > 0) {
158
200
  options = f.options.map((o) => ({
@@ -165,6 +207,28 @@ export function useDynamicFilters(
165
207
  if (f.searchEndpoint && filterOptionsMap.has(f.searchEndpoint)) {
166
208
  options = filterOptionsMap.get(f.searchEndpoint) || []
167
209
  }
210
+ // (A) Stage column with no options of its own → project the pipeline
211
+ // stages (with their colors) into a real select instead of leaving it as
212
+ // a text box.
213
+ if (
214
+ options.length === 0 &&
215
+ !f.searchEndpoint &&
216
+ (f.column || f.key) === groupBy &&
217
+ stageOptions.length > 0
218
+ ) {
219
+ fType = 'select'
220
+ options = stageOptions
221
+ }
222
+ // (B) A plain text filter becomes a facet value-picker when a facets
223
+ // endpoint is available.
224
+ let loadOptions: ColumnFilterConfig['loadOptions']
225
+ if (fType === 'text' && facetsBase) {
226
+ const loader = getFacetLoader(f.column || f.key)
227
+ if (loader) {
228
+ fType = 'facet'
229
+ loadOptions = loader
230
+ }
231
+ }
168
232
  if (fType === 'select' && options.length === 0 && !f.searchEndpoint) continue
169
233
  map.set(f.key, {
170
234
  filterType: fType,
@@ -174,6 +238,7 @@ export function useDynamicFilters(
174
238
  onFilterChange: handleDynamicFilterChange,
175
239
  loading: f.searchEndpoint ? !filterOptionsMap.has(f.searchEndpoint) : false,
176
240
  searchEndpoint: f.searchEndpoint,
241
+ loadOptions,
177
242
  })
178
243
  }
179
244
  for (const c of metadata.columns ?? []) {
@@ -181,8 +246,17 @@ export function useDynamicFilters(
181
246
  const hasStaticOptions = (c.options?.length ?? 0) > 0
182
247
  const hasEndpoint = !!c.searchEndpoint
183
248
  const isRelation = !!c.ref || c.filterType === 'dynamic_select'
249
+ // (A) A stage column with no options of its own inherits the pipeline's
250
+ // stages — so "Stage" becomes a colored select, not a text box.
251
+ const isStageColumn =
252
+ c.key === groupBy &&
253
+ !hasStaticOptions &&
254
+ !hasEndpoint &&
255
+ !c.filterType &&
256
+ stageOptions.length > 0
184
257
  let filterType: ColumnFilterConfig['filterType']
185
- if (c.filterType) filterType = c.filterType
258
+ if (isStageColumn) filterType = 'select'
259
+ else if (c.filterType) filterType = c.filterType
186
260
  else if (isRelation && hasEndpoint) filterType = 'dynamic_select'
187
261
  else if (hasStaticOptions || hasEndpoint) filterType = 'select'
188
262
  else if (c.type === 'boolean') filterType = 'boolean'
@@ -191,7 +265,7 @@ export function useDynamicFilters(
191
265
  filterType = 'date_range'
192
266
  else filterType = 'text'
193
267
 
194
- const options = hasStaticOptions
268
+ let options = hasStaticOptions
195
269
  ? c.options!.map((o) => ({
196
270
  label: o.label,
197
271
  value: String(o.value),
@@ -201,6 +275,19 @@ export function useDynamicFilters(
201
275
  : hasEndpoint && filterOptionsMap.has(c.searchEndpoint!)
202
276
  ? filterOptionsMap.get(c.searchEndpoint!) || []
203
277
  : []
278
+ if (isStageColumn) options = stageOptions
279
+
280
+ // (B) Upgrade a plain text filter to a facet value-picker (unless it's a
281
+ // long-text/body column — too many unique values to enumerate).
282
+ let loadOptions: ColumnFilterConfig['loadOptions']
283
+ if (filterType === 'text' && facetsBase && !isLongTextColumn(c)) {
284
+ const loader = getFacetLoader(c.key)
285
+ if (loader) {
286
+ filterType = 'facet'
287
+ loadOptions = loader
288
+ }
289
+ }
290
+
204
291
  map.set(c.key, {
205
292
  filterType,
206
293
  filterKey: c.key,
@@ -209,10 +296,18 @@ export function useDynamicFilters(
209
296
  onFilterChange: handleDynamicFilterChange,
210
297
  loading: hasEndpoint && !filterOptionsMap.has(c.searchEndpoint!),
211
298
  searchEndpoint: c.searchEndpoint,
299
+ loadOptions,
212
300
  })
213
301
  }
214
302
  return map
215
- }, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange])
303
+ }, [
304
+ metadata,
305
+ filterOptionsMap,
306
+ dynamicFilters,
307
+ handleDynamicFilterChange,
308
+ facetsBase,
309
+ getFacetLoader,
310
+ ])
216
311
 
217
312
  const searchableKeys = useMemo(
218
313
  () => (metadata ? getSearchableColumnKeys(metadata) : null),
@@ -0,0 +1,79 @@
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 } from 'react'
8
+ import { useApi } from './api-context'
9
+ import type { FilterOption } from './dynamic-columns-shim'
10
+
11
+ /**
12
+ * Long-text / body columns aren't worth faceting (thousands of unique values,
13
+ * each a paragraph). Heuristic on the metadata, not a hardcoded name list:
14
+ * a truncate-text/widget cell or a json/long-text SQL type are the strong
15
+ * signals; the name pattern is only a last-resort fallback.
16
+ */
17
+ export function isLongTextColumn(c: {
18
+ key: string
19
+ type?: string
20
+ cellStyle?: string
21
+ }): boolean {
22
+ if (c.cellStyle === 'truncate-text' || c.cellStyle === 'widget') return true
23
+ const t = (c.type || '').toLowerCase()
24
+ if (t === 'json' || t === 'long_text' || t === 'text_long' || t === 'widget')
25
+ return true
26
+ return /^(body|description|content|notes?|comment|message|summary)$/i.test(
27
+ c.key,
28
+ )
29
+ }
30
+
31
+ /**
32
+ * Returns `getFacetLoader(field)` — a stable per-field loader that resolves the
33
+ * column's distinct values + counts from `<facetsBase>?field=&q=&limit=`. Loader
34
+ * identity is memoized per field (so it doesn't churn the config memo that
35
+ * depends on it) and results are cached per `field+query`. Both caches reset
36
+ * when `facetsBase` changes (model switch). A null `facetsBase` returns a
37
+ * factory that yields `undefined` — the caller keeps the column as plain text.
38
+ */
39
+ export function useFacetLoaders(facetsBase: string | null) {
40
+ const api = useApi()
41
+ const loaderCache = useRef<
42
+ Map<string, (q?: string) => Promise<FilterOption[]>>
43
+ >(new Map())
44
+ const resultCache = useRef<Map<string, FilterOption[]>>(new Map())
45
+
46
+ useEffect(() => {
47
+ loaderCache.current.clear()
48
+ resultCache.current.clear()
49
+ }, [facetsBase])
50
+
51
+ return useCallback(
52
+ (field: string) => {
53
+ if (!facetsBase) return undefined
54
+ const existing = loaderCache.current.get(field)
55
+ if (existing) return existing
56
+ const loader = async (q?: string): Promise<FilterOption[]> => {
57
+ const term = q?.trim() || ''
58
+ const cacheKey = `${field}::${term}`
59
+ const cached = resultCache.current.get(cacheKey)
60
+ if (cached) return cached
61
+ const res = await api.get(facetsBase, {
62
+ params: { field, q: term || undefined, limit: 50 },
63
+ })
64
+ const body = (res as { data: any }).data
65
+ const rows: any[] = body?.success ? body.data || [] : []
66
+ const opts: FilterOption[] = rows.map((r) => ({
67
+ value: String(r.value ?? ''),
68
+ label: String(r.label ?? r.value ?? ''),
69
+ count: typeof r.count === 'number' ? r.count : undefined,
70
+ }))
71
+ resultCache.current.set(cacheKey, opts)
72
+ return opts
73
+ }
74
+ loaderCache.current.set(field, loader)
75
+ return loader
76
+ },
77
+ [api, facetsBase],
78
+ )
79
+ }