@asteby/metacore-runtime-react 23.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.
@@ -40,6 +40,7 @@ import {
40
40
  selectCardColumns,
41
41
  cardMatchesLaneQuery,
42
42
  cardMatchesLaneFunnel,
43
+ laneFunnelCount,
43
44
  translateOptionLabels,
44
45
  summarizeFilterValues,
45
46
  UNASSIGNED_LANE,
@@ -283,6 +284,18 @@ describe('cardMatchesLaneFunnel', () => {
283
284
  })
284
285
  })
285
286
 
287
+ describe('laneFunnelCount', () => {
288
+ it('counts picked values, else 1 for free text, else 0', () => {
289
+ expect(laneFunnelCount(undefined)).toBe(0)
290
+ expect(laneFunnelCount({})).toBe(0)
291
+ expect(laneFunnelCount({ values: ['a', 'b', 'c'] })).toBe(3)
292
+ expect(laneFunnelCount({ text: 'foo' })).toBe(1)
293
+ expect(laneFunnelCount({ text: ' ' })).toBe(0)
294
+ // picked values win over stray text
295
+ expect(laneFunnelCount({ values: ['a'], text: 'x' })).toBe(1)
296
+ })
297
+ })
298
+
286
299
  describe('translateOptionLabels', () => {
287
300
  it('runs option labels through the translator (stage i18n keys → localized)', () => {
288
301
  const opts = [
@@ -528,4 +541,31 @@ describe('DynamicKanban lane funnel', () => {
528
541
  ).toBeTruthy()
529
542
  expect(screen.queryByPlaceholderText('Contiene...')).toBeNull()
530
543
  })
544
+
545
+ it('shows a count badge on the funnel with the number of picked values', async () => {
546
+ useMetadataCache.getState().setMetadata('issue', meta())
547
+ render(
548
+ <ApiProvider client={fakeApi()}>
549
+ <DynamicKanban model="issue" />
550
+ </ApiProvider>,
551
+ )
552
+ await screen.findByText('Fix login bug')
553
+ fireEvent.click(screen.getAllByLabelText('Filtrar columna')[0])
554
+ await screen.findByPlaceholderText('Buscar valores...')
555
+
556
+ // pick two stage values from the combobox
557
+ const options = screen.getAllByRole('option')
558
+ expect(options.length).toBeGreaterThanOrEqual(2)
559
+ fireEvent.click(options[0])
560
+ fireEvent.click(options[1])
561
+ // apply
562
+ fireEvent.click(screen.getByText(/Aplicar/))
563
+
564
+ // the first lane's funnel button now carries a "2" count badge
565
+ await waitFor(() =>
566
+ expect(
567
+ screen.getAllByLabelText('Filtrar columna')[0].textContent,
568
+ ).toContain('2'),
569
+ )
570
+ })
531
571
  })
@@ -0,0 +1,102 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // DynamicTable filter parity with the kanban:
4
+ // - Facet PREFETCH: on metadata resolve, the table warms every facet field in
5
+ // one burst (`/data/<model>/facets?field=<key>`), so a text-column header
6
+ // filter opens instantly.
7
+ // - Stage-select: the group_by/stage column with no options of its own becomes
8
+ // a select (NOT a facet) — so it must NOT trigger a facets prewarm.
9
+ // - Long-text columns stay plain text (no facet, no prewarm).
10
+ import { afterEach, describe, expect, it, vi } from 'vitest'
11
+ import { cleanup, render, screen, waitFor } from '@testing-library/react'
12
+
13
+ vi.mock('@tanstack/react-router', () => ({
14
+ useNavigate: () => () => {},
15
+ }))
16
+ const I18N = { t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k, i18n: { language: 'es' } }
17
+ vi.mock('react-i18next', () => ({ useTranslation: () => I18N }))
18
+
19
+ import { DynamicTable } from '../dynamic-table'
20
+ import { ApiProvider, type ApiClient } from '../api-context'
21
+ import { useMetadataCache } from '../metadata-cache'
22
+ import type { TableMetadata } from '../types'
23
+
24
+ afterEach(cleanup)
25
+
26
+ const STAGES = [
27
+ { key: 'backlog', label: 'issue.stage.backlog', color: 'slate', order: 0 },
28
+ { key: 'done', label: 'issue.stage.done', color: 'green', order: 1 },
29
+ ]
30
+
31
+ function meta(): TableMetadata {
32
+ return {
33
+ title: 'Issues',
34
+ endpoint: '/data/issue',
35
+ group_by: 'stage',
36
+ stages: STAGES,
37
+ columns: [
38
+ { key: 'title', label: 'Title', type: 'text', sortable: true, filterable: true, searchable: true },
39
+ { key: 'body', label: 'Body', type: 'text', sortable: false, filterable: true, cellStyle: 'truncate-text' },
40
+ { key: 'stage', label: 'Stage', type: 'status', sortable: false, filterable: true },
41
+ ],
42
+ actions: [],
43
+ perPageOptions: [50],
44
+ defaultPerPage: 50,
45
+ searchPlaceholder: 'Buscar...',
46
+ enableCRUDActions: true,
47
+ hasActions: false,
48
+ }
49
+ }
50
+
51
+ function fakeApi(): ApiClient {
52
+ const ok = (data: unknown) => ({ data: { success: true, data, meta: { total: 0 } } })
53
+ return {
54
+ get: vi.fn(async (url: string, cfg?: any) => {
55
+ if (url.startsWith('/metadata/table/')) return ok(meta())
56
+ if (url.endsWith('/facets')) {
57
+ const f = cfg?.params?.field
58
+ return ok([
59
+ { value: `${f}-a`, label: `${f} A`, count: 2 },
60
+ { value: `${f}-b`, label: `${f} B`, count: 1 },
61
+ ])
62
+ }
63
+ return ok([])
64
+ }),
65
+ post: vi.fn(async () => ok(null)),
66
+ put: vi.fn(async () => ok(null)),
67
+ delete: vi.fn(async () => ok(null)),
68
+ }
69
+ }
70
+
71
+ function facetFields(api: ApiClient): string[] {
72
+ return (api.get as any).mock.calls
73
+ .filter((c: any[]) => String(c[0]).endsWith('/facets'))
74
+ .map((c: any[]) => c[1]?.params?.field)
75
+ }
76
+
77
+ describe('DynamicTable facet prefetch + stage-select', () => {
78
+ it('prewarms the plain text column but NOT the stage (select) or long-text columns', async () => {
79
+ useMetadataCache.getState().setMetadata('issue', meta())
80
+ const api = fakeApi()
81
+ render(
82
+ <ApiProvider client={api}>
83
+ <DynamicTable model="issue" />
84
+ </ApiProvider>,
85
+ )
86
+ // 'title' is a facet → it gets prewarmed against the model-derived endpoint
87
+ await waitFor(() =>
88
+ expect(
89
+ (api.get as any).mock.calls.some(
90
+ (c: any[]) =>
91
+ String(c[0]) === '/data/issue/facets' &&
92
+ c[1]?.params?.field === 'title',
93
+ ),
94
+ ).toBe(true),
95
+ )
96
+ const fields = facetFields(api)
97
+ // stage → select (from the pipeline stages), never faceted
98
+ expect(fields).not.toContain('stage')
99
+ // body → long-text, stays plain text, never faceted
100
+ expect(fields).not.toContain('body')
101
+ })
102
+ })
@@ -0,0 +1,119 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // FilterChipsRow — the removable active-filter chip row shared by DynamicTable
4
+ // and DynamicKanban: one chip per active field (label + summarized value + a
5
+ // value-color dot when the option carries a color), an X that clears that field,
6
+ // and a trailing "Limpiar todo".
7
+ import { afterEach, describe, expect, it, vi } from 'vitest'
8
+ import { cleanup, render, screen, fireEvent } from '@testing-library/react'
9
+
10
+ vi.mock('react-i18next', () => ({
11
+ useTranslation: () => ({
12
+ t: (_k: string, opts?: { defaultValue?: string }) => opts?.defaultValue ?? _k,
13
+ }),
14
+ }))
15
+
16
+ import {
17
+ FilterChipsRow,
18
+ summarizeFilterValues,
19
+ chipValueColor,
20
+ type FilterChipField,
21
+ } from '../filter-chips'
22
+
23
+ afterEach(cleanup)
24
+
25
+ function field(over: Partial<FilterChipField> & { key: string }): FilterChipField {
26
+ return {
27
+ label: over.key,
28
+ config: {
29
+ selectedValues: [],
30
+ options: [],
31
+ filterKey: over.key,
32
+ onFilterChange: vi.fn(),
33
+ ...(over.config as any),
34
+ },
35
+ ...over,
36
+ }
37
+ }
38
+
39
+ describe('summarizeFilterValues / chipValueColor', () => {
40
+ const opts = [
41
+ { value: 'backlog', label: 'Pendiente', color: 'slate' },
42
+ { value: 'done', label: 'Hecho', color: 'green' },
43
+ ]
44
+ it('summarizes a single value to its (translated) option label', () => {
45
+ expect(summarizeFilterValues(['done'], opts)).toBe('Hecho')
46
+ })
47
+ it('resolves the option color for the chip dot', () => {
48
+ expect(chipValueColor({ selectedValues: ['done'], options: opts })).toBeTruthy()
49
+ })
50
+ it('has no dot for a free-text (ILIKE) value', () => {
51
+ expect(
52
+ chipValueColor({ selectedValues: ['ILIKE:foo'], options: opts }),
53
+ ).toBeUndefined()
54
+ })
55
+ })
56
+
57
+ describe('FilterChipsRow', () => {
58
+ const stageOptions = [
59
+ { value: 'backlog', label: 'Pendiente', color: 'slate' },
60
+ { value: 'done', label: 'Hecho', color: 'green' },
61
+ ]
62
+
63
+ it('renders nothing when there are no active fields', () => {
64
+ const { container } = render(
65
+ <FilterChipsRow fields={[]} onClearAll={() => {}} />,
66
+ )
67
+ expect(container.firstChild).toBeNull()
68
+ })
69
+
70
+ it('renders a chip per active field with its translated value summary', () => {
71
+ render(
72
+ <FilterChipsRow
73
+ fields={[
74
+ field({
75
+ key: 'stage',
76
+ label: 'Etapa',
77
+ config: {
78
+ selectedValues: ['done'],
79
+ options: stageOptions,
80
+ filterKey: 'stage',
81
+ onFilterChange: vi.fn(),
82
+ },
83
+ }),
84
+ ]}
85
+ onClearAll={() => {}}
86
+ data-testid="chips"
87
+ />,
88
+ )
89
+ expect(screen.getByText('Etapa:')).toBeTruthy()
90
+ // the value shows the TRANSLATED option label, not the raw key
91
+ expect(screen.getByText('Hecho')).toBeTruthy()
92
+ })
93
+
94
+ it('clears one field via its X and everything via "Limpiar todo"', () => {
95
+ const onFilterChange = vi.fn()
96
+ const onClearAll = vi.fn()
97
+ render(
98
+ <FilterChipsRow
99
+ fields={[
100
+ field({
101
+ key: 'stage',
102
+ label: 'Etapa',
103
+ config: {
104
+ selectedValues: ['done'],
105
+ options: stageOptions,
106
+ filterKey: 'stage',
107
+ onFilterChange,
108
+ },
109
+ }),
110
+ ]}
111
+ onClearAll={onClearAll}
112
+ />,
113
+ )
114
+ fireEvent.click(screen.getByLabelText('Quitar filtro'))
115
+ expect(onFilterChange).toHaveBeenCalledWith('stage', [])
116
+ fireEvent.click(screen.getByText('Limpiar todo'))
117
+ expect(onClearAll).toHaveBeenCalled()
118
+ })
119
+ })
@@ -77,9 +77,14 @@ import {
77
77
  Skeleton,
78
78
  } from '@asteby/metacore-ui/primitives'
79
79
  import { ColumnFilterControl, FilterValueCombobox, type ColumnFilterType } from '@asteby/metacore-ui/data-table'
80
- import { generateBadgeStyles, optionColor, resolveColorCss } from '@asteby/metacore-ui/lib'
80
+ import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib'
81
81
  import { useApi } from './api-context'
82
82
  import { useDynamicFilters } from './use-dynamic-filters'
83
+ import {
84
+ FilterChipsRow,
85
+ summarizeFilterValues,
86
+ translateOptionLabels,
87
+ } from './filter-chips'
83
88
  import { useMetadataCache } from './metadata-cache'
84
89
  import { ActivityValueRenderer } from './activity-value-renderer'
85
90
  import { DynamicIcon } from './dynamic-icon'
@@ -96,6 +101,10 @@ import type {
96
101
  StageTransition,
97
102
  } from './types'
98
103
 
104
+ // Re-exported for tests + backward-compat: these live in ./filter-chips now
105
+ // (shared with DynamicTable) but were historically imported from here.
106
+ export { summarizeFilterValues, translateOptionLabels }
107
+
99
108
  // ---------------------------------------------------------------------------
100
109
  // Pure helpers (exported for unit tests — no React, no transport)
101
110
  // ---------------------------------------------------------------------------
@@ -233,71 +242,6 @@ export function selectCardColumns(
233
242
  return { title, fields }
234
243
  }
235
244
 
236
- /**
237
- * Human-readable summary of a field's selected filter values for the removable
238
- * chip row. Resolves option labels, unwraps the wire operators
239
- * (`IN:`/`ILIKE:`/`RANGE:`/`GTE:`/`LTE:`/date `from_to`), and caps the list at
240
- * `maxShown` values with a `+n` overflow. Pure — exported for unit tests.
241
- */
242
- export function summarizeFilterValues(
243
- values: string[] | undefined,
244
- options: { label: string; value: string }[] | undefined,
245
- maxShown = 2,
246
- ): string {
247
- if (!values || values.length === 0) return ''
248
- const opts = options ?? []
249
- const labelFor = (v: string) =>
250
- opts.find((o) => o.value === v)?.label ?? v
251
- const first = values[0]
252
- if (values.length === 1) {
253
- if (first.startsWith('ILIKE:')) return `"${first.slice(6)}"`
254
- if (first.startsWith('IN:')) {
255
- return summarizeList(first.slice(3).split(','), labelFor, maxShown)
256
- }
257
- if (first.startsWith('RANGE:')) {
258
- const [min, max] = first.slice(6).split(',')
259
- return `${min || '…'} – ${max || '…'}`
260
- }
261
- if (/^\d{4}-\d{2}-\d{2}_/.test(first)) return first.replace('_', ' – ')
262
- }
263
- if (first.startsWith('GTE:') || first.startsWith('LTE:')) {
264
- const min = values.find((v) => v.startsWith('GTE:'))?.slice(4) ?? ''
265
- const max = values.find((v) => v.startsWith('LTE:'))?.slice(4) ?? ''
266
- return `${min || '…'} – ${max || '…'}`
267
- }
268
- return summarizeList(values, labelFor, maxShown)
269
- }
270
-
271
- function summarizeList(
272
- items: string[],
273
- labelFor: (v: string) => string,
274
- maxShown: number,
275
- ): string {
276
- const labels = items.map(labelFor)
277
- if (labels.length <= maxShown) return labels.join(', ')
278
- return `${labels.slice(0, maxShown).join(', ')} +${labels.length - maxShown}`
279
- }
280
-
281
- /**
282
- * The color of a filter's first selected value (for the chip's dot) — e.g. a
283
- * stage's palette color. Returns a resolved CSS color, or undefined for
284
- * operator/range/free-text values that carry no option color.
285
- */
286
- function chipValueColor(config: {
287
- selectedValues: string[]
288
- options: { value: string; color?: string }[]
289
- }): string | undefined {
290
- const sel = config.selectedValues
291
- if (!sel || sel.length === 0) return undefined
292
- const first = sel[0]
293
- let value = first
294
- if (first.startsWith('IN:')) value = first.slice(3).split(',')[0]
295
- else if (/^(ILIKE|RANGE|GTE|LTE):/.test(first)) return undefined
296
- else if (/^\d{4}-\d{2}-\d{2}_/.test(first)) return undefined
297
- const opt = config.options.find((o) => o.value === value)
298
- return opt?.color ? resolveColorCss(opt.color) : undefined
299
- }
300
-
301
245
  /**
302
246
  * Whether a card passes a lane funnel. Picked select/facet `values` match by
303
247
  * equality (IN — the card's field value must be one of them); a free-text
@@ -320,14 +264,16 @@ export function cardMatchesLaneFunnel(
320
264
  }
321
265
 
322
266
  /**
323
- * Translates option labels through the app translator (manifest i18n keys
324
- * localized text). A raw value with no matching key falls through to itself via
325
- * `defaultValue`. Pure — exported for unit tests.
267
+ * Count of applied criteria on a lane funnel: the number of picked select/facet
268
+ * `values`, else 1 for a free-text `text`, else 0. Drives the funnel's count
269
+ * badge. Pure — exported for unit tests.
326
270
  */
327
- export function translateOptionLabels<
328
- T extends { label: string },
329
- >(options: T[], translate: (key: string) => string): T[] {
330
- return options.map((o) => ({ ...o, label: translate(o.label) }))
271
+ export function laneFunnelCount(
272
+ value: { values?: string[]; text?: string } | undefined,
273
+ ): number {
274
+ if (value?.values?.length) return value.values.length
275
+ if (value?.text?.trim()) return 1
276
+ return 0
331
277
  }
332
278
 
333
279
  /**
@@ -878,64 +824,13 @@ export function DynamicKanban({
878
824
  )}
879
825
  </div>
880
826
 
881
- {/* Removable chip row — one chip per active field filter, plus a
882
- "Limpiar todo" at the end. Instant feedback without opening the
883
- Sheet; clicking a chip's X clears that field server-side. */}
884
- {activeFields.length > 0 && (
885
- <div
886
- className="flex flex-wrap items-center gap-1.5"
887
- data-testid="kanban-filter-chips"
888
- >
889
- {activeFields.map((field) => {
890
- const summary = summarizeFilterValues(
891
- field.config.selectedValues,
892
- field.config.options,
893
- )
894
- const dot = chipValueColor(field.config)
895
- return (
896
- <Badge
897
- key={field.key}
898
- variant="secondary"
899
- className="h-6 gap-1.5 rounded-md pl-2 pr-1 text-xs font-normal"
900
- >
901
- {dot && (
902
- <span
903
- className="size-2 shrink-0 rounded-full"
904
- style={{ backgroundColor: dot }}
905
- />
906
- )}
907
- <span className="font-medium">{field.label}:</span>
908
- <span className="max-w-[180px] truncate text-muted-foreground">
909
- {summary}
910
- </span>
911
- <button
912
- type="button"
913
- onClick={() =>
914
- field.config.onFilterChange(
915
- field.config.filterKey,
916
- [],
917
- )
918
- }
919
- className="ml-0.5 rounded-sm p-0.5 transition-colors hover:bg-muted-foreground/20"
920
- aria-label={t('kanban.removeFilter', {
921
- defaultValue: 'Quitar filtro',
922
- })}
923
- >
924
- <X className="h-3 w-3" />
925
- </button>
926
- </Badge>
927
- )
928
- })}
929
- <Button
930
- variant="ghost"
931
- size="sm"
932
- className="h-6 gap-1 px-2 text-xs text-muted-foreground"
933
- onClick={clearAll}
934
- >
935
- {t('kanban.clearAll', { defaultValue: 'Limpiar todo' })}
936
- </Button>
937
- </div>
938
- )}
827
+ {/* Removable chip row — shared with DynamicTable. Instant feedback
828
+ without opening the Sheet; a chip's X clears that field. */}
829
+ <FilterChipsRow
830
+ fields={activeFields}
831
+ onClearAll={clearAll}
832
+ data-testid="kanban-filter-chips"
833
+ />
939
834
 
940
835
  <DndContext sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd}>
941
836
  <div className="flex min-w-0 gap-4 overflow-x-auto p-1" data-testid="kanban-board">
@@ -1219,18 +1114,14 @@ function KanbanLane({
1219
1114
  {laneActive ? `${count}/${totalCount}` : count}
1220
1115
  </span>
1221
1116
  </div>
1222
- {/* Lane actions — dimmed until the lane (or the board row) is
1223
- hovered/focused, so an idle board stays clean. Stay lit when a
1224
- lane filter is active. */}
1225
- <div
1226
- className={`flex items-center gap-0.5 transition-opacity focus-within:opacity-100 group-hover/lane:opacity-100 ${
1227
- laneActive || searchOpen ? 'opacity-100' : 'opacity-0'
1228
- }`}
1229
- >
1117
+ {/* Lane actions — always visible in muted (a hidden hover-reveal
1118
+ was undiscoverable); active state is a primary tint + a count
1119
+ badge on the funnel. */}
1120
+ <div className="flex items-center gap-0.5">
1230
1121
  <button
1231
1122
  type="button"
1232
1123
  onClick={() => setSearchOpen((o) => !o)}
1233
- className={`flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
1124
+ className={`relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
1234
1125
  queryActive ? 'text-primary' : 'text-muted-foreground'
1235
1126
  }`}
1236
1127
  aria-label={t('kanban.searchLane', {
@@ -1238,6 +1129,9 @@ function KanbanLane({
1238
1129
  })}
1239
1130
  >
1240
1131
  <Search className="h-3.5 w-3.5" />
1132
+ {queryActive && (
1133
+ <span className="absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-primary" />
1134
+ )}
1241
1135
  </button>
1242
1136
  <LaneFilterButton
1243
1137
  fields={filterFields}
@@ -1332,6 +1226,8 @@ function LaneFilterButton({
1332
1226
  value &&
1333
1227
  ((value.values && value.values.length > 0) || value.text?.trim())
1334
1228
  )
1229
+ // Number of applied criteria on this lane's funnel (drives the count badge).
1230
+ const activeCount = laneFunnelCount(value)
1335
1231
  // The value step mirrors the sheet: when the chosen field is a select or a
1336
1232
  // facet (static options OR a lazy loader), render the SAME pro combobox —
1337
1233
  // multi-select, searchable, with counts. Only a genuinely free-text field
@@ -1357,7 +1253,7 @@ function LaneFilterButton({
1357
1253
  <PopoverTrigger asChild>
1358
1254
  <button
1359
1255
  type="button"
1360
- className={`flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
1256
+ className={`relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
1361
1257
  active ? 'text-primary' : 'text-muted-foreground'
1362
1258
  }`}
1363
1259
  aria-label={t('kanban.filterLane', {
@@ -1365,6 +1261,11 @@ function LaneFilterButton({
1365
1261
  })}
1366
1262
  >
1367
1263
  <ListFilter className="h-3.5 w-3.5" />
1264
+ {activeCount > 0 && (
1265
+ <span className="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-0.5 text-[9px] font-bold leading-none text-primary-foreground tabular-nums">
1266
+ {activeCount}
1267
+ </span>
1268
+ )}
1368
1269
  </button>
1369
1270
  </PopoverTrigger>
1370
1271
  <PopoverContent
@@ -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