@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.
- package/CHANGELOG.md +29 -0
- package/dist/dynamic-kanban.d.ts +9 -16
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +21 -75
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +60 -7
- package/dist/filter-chips.d.ts +60 -0
- package/dist/filter-chips.d.ts.map +1 -0
- package/dist/filter-chips.js +93 -0
- package/package.json +3 -3
- package/src/__tests__/dynamic-kanban.test.tsx +40 -0
- package/src/__tests__/dynamic-table-filters.test.tsx +102 -0
- package/src/__tests__/filter-chips.test.tsx +119 -0
- package/src/dynamic-kanban.tsx +42 -141
- package/src/dynamic-table.tsx +69 -6
- package/src/filter-chips.tsx +175 -0
|
@@ -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
|
+
})
|
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
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
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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 —
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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 —
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -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
|