@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.
@@ -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 = useFacetLoaders(facetsBase)
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],
@@ -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
- * 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.
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
- return useCallback(
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
  }