@asteby/metacore-runtime-react 23.0.0 → 23.2.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.
@@ -0,0 +1,210 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // DynamicKanban incremental (per-lane) loading:
4
+ // 1. applyLaneTotalsOnMove — the pure count adjustment that keeps a partial
5
+ // lane's `count/total` header truthful across an optimistic drag.
6
+ // 2. A lane tops up its OWN stage on scroll: firing the sentinel issues
7
+ // `f_<group_by>=<stage>&page=1&per_page=25` (on top of the active filters)
8
+ // and appends the rows, with the header total taken from the response meta.
9
+ // 3. Changing the filters re-fetches the initial board page (resets the
10
+ // per-lane pagination).
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
12
+ import { cleanup, render, screen, waitFor } from '@testing-library/react'
13
+
14
+ vi.mock('@tanstack/react-router', () => ({
15
+ useNavigate: () => () => {},
16
+ }))
17
+ const I18N = { t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k, i18n: { language: 'es' } }
18
+ vi.mock('react-i18next', () => ({ useTranslation: () => I18N }))
19
+
20
+ import { applyLaneTotalsOnMove, DynamicKanban } from '../dynamic-kanban'
21
+ import { ApiProvider, type ApiClient } from '../api-context'
22
+ import { useMetadataCache } from '../metadata-cache'
23
+ import type { TableMetadata } from '../types'
24
+
25
+ afterEach(cleanup)
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // 1. Pure count adjustment
29
+ // ---------------------------------------------------------------------------
30
+
31
+ describe('applyLaneTotalsOnMove', () => {
32
+ it('decrements the source total and increments the destination total', () => {
33
+ const out = applyLaneTotalsOnMove(
34
+ { backlog: { total: 5 }, done: { total: 2 } },
35
+ 'backlog',
36
+ 'done',
37
+ )
38
+ expect(out.backlog.total).toBe(4)
39
+ expect(out.done.total).toBe(3)
40
+ })
41
+
42
+ it('leaves lanes with an unknown (null) total alone', () => {
43
+ const out = applyLaneTotalsOnMove(
44
+ { backlog: { total: null }, done: { total: 2 } },
45
+ 'backlog',
46
+ 'done',
47
+ )
48
+ expect(out.backlog.total).toBeNull()
49
+ expect(out.done.total).toBe(3)
50
+ })
51
+
52
+ it('never drives a total below zero', () => {
53
+ const out = applyLaneTotalsOnMove(
54
+ { backlog: { total: 0 }, done: { total: 1 } },
55
+ 'backlog',
56
+ 'done',
57
+ )
58
+ expect(out.backlog.total).toBe(0)
59
+ expect(out.done.total).toBe(2)
60
+ })
61
+
62
+ it('does not mutate the input map', () => {
63
+ const input = { backlog: { total: 5 }, done: { total: 2 } }
64
+ applyLaneTotalsOnMove(input, 'backlog', 'done')
65
+ expect(input.backlog.total).toBe(5)
66
+ expect(input.done.total).toBe(2)
67
+ })
68
+ })
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // 2 + 3. Per-lane top-up + reset
72
+ // ---------------------------------------------------------------------------
73
+
74
+ const STAGES = [
75
+ { key: 'backlog', label: 'Backlog', color: 'slate', order: 0 },
76
+ { key: 'done', label: 'Done', color: 'green', order: 1 },
77
+ ]
78
+
79
+ function meta(over: Partial<TableMetadata> = {}): TableMetadata {
80
+ return {
81
+ title: 'Issues',
82
+ endpoint: '/data/issue',
83
+ view_type: 'kanban',
84
+ group_by: 'stage',
85
+ stages: STAGES,
86
+ columns: [
87
+ { key: 'title', label: 'Title', type: 'text', sortable: true, filterable: false, searchable: true },
88
+ { key: 'stage', label: 'Stage', type: 'status', sortable: false, filterable: true, options: STAGES.map((s) => ({ value: s.key, label: s.label })) },
89
+ ],
90
+ actions: [],
91
+ perPageOptions: [50],
92
+ defaultPerPage: 50,
93
+ searchPlaceholder: 'Buscar...',
94
+ enableCRUDActions: false,
95
+ hasActions: false,
96
+ ...over,
97
+ }
98
+ }
99
+
100
+ // Initial global page: one backlog card. A backlog top-up (f_stage=backlog)
101
+ // returns two more backlog cards with a server total of 5.
102
+ function fakeApi(): ApiClient {
103
+ const ok = (data: unknown, meta?: Record<string, unknown>) => ({
104
+ data: { success: true, data, ...(meta ? { meta } : {}) },
105
+ })
106
+ return {
107
+ get: vi.fn(async (url: string, cfg?: any) => {
108
+ if (url.startsWith('/metadata/table/')) return ok(meta())
109
+ const stage = cfg?.params?.f_stage
110
+ if (stage === 'backlog') {
111
+ return ok(
112
+ [
113
+ { id: 2, title: 'Backlog Two', stage: 'backlog' },
114
+ { id: 3, title: 'Backlog Three', stage: 'backlog' },
115
+ ],
116
+ { total: 5 },
117
+ )
118
+ }
119
+ if (stage === 'done') return ok([], { total: 0 })
120
+ // initial global board page
121
+ return ok([{ id: 1, title: 'Backlog One', stage: 'backlog' }])
122
+ }),
123
+ post: vi.fn(async () => ok(null)),
124
+ put: vi.fn(async () => ok(null)),
125
+ delete: vi.fn(async () => ok(null)),
126
+ }
127
+ }
128
+
129
+ function stageCalls(api: ApiClient, stage: string) {
130
+ return (api.get as any).mock.calls.filter(
131
+ (c: any[]) => c[1]?.params?.f_stage === stage,
132
+ )
133
+ }
134
+ function globalDataCalls(api: ApiClient) {
135
+ return (api.get as any).mock.calls.filter(
136
+ (c: any[]) =>
137
+ !String(c[0]).startsWith('/metadata/table/') &&
138
+ c[1]?.params?.f_stage === undefined,
139
+ )
140
+ }
141
+
142
+ // Controllable IntersectionObserver.
143
+ type IOEntry = { isIntersecting: boolean }
144
+ let observers: Array<{ fire: (v: boolean) => void }> = []
145
+ class FakeIO {
146
+ cb: (e: IOEntry[]) => void
147
+ constructor(cb: (e: IOEntry[]) => void) {
148
+ this.cb = cb
149
+ observers.push({ fire: (v: boolean) => this.cb([{ isIntersecting: v }]) })
150
+ }
151
+ observe() {}
152
+ disconnect() {}
153
+ }
154
+
155
+ describe('DynamicKanban per-lane infinite scroll', () => {
156
+ beforeEach(() => {
157
+ observers = []
158
+ ;(globalThis as any).IntersectionObserver = FakeIO
159
+ })
160
+ afterEach(() => {
161
+ delete (globalThis as any).IntersectionObserver
162
+ })
163
+
164
+ it('tops up ONE lane by stage on scroll and shows the server total', async () => {
165
+ useMetadataCache.getState().setMetadata('issue', meta())
166
+ const api = fakeApi()
167
+ render(
168
+ <ApiProvider client={api}>
169
+ <DynamicKanban model="issue" />
170
+ </ApiProvider>,
171
+ )
172
+ // Initial board page painted its single backlog card.
173
+ expect(await screen.findByText('Backlog One')).toBeTruthy()
174
+ expect(stageCalls(api, 'backlog')).toHaveLength(0)
175
+
176
+ // Sentinels enter view → each declared lane tops up its OWN stage.
177
+ expect(observers.length).toBeGreaterThan(0)
178
+ observers.forEach((o) => o.fire(true))
179
+
180
+ // The backlog top-up ran with the right scoped params...
181
+ await waitFor(() => expect(stageCalls(api, 'backlog').length).toBeGreaterThan(0))
182
+ const call = stageCalls(api, 'backlog')[0]
183
+ expect(call[1].params).toMatchObject({ f_stage: 'backlog', page: 1, per_page: 25 })
184
+
185
+ // ...its rows were appended and the header shows count/serverTotal (3/5).
186
+ expect(await screen.findByText('Backlog Two')).toBeTruthy()
187
+ expect(await screen.findByText('3/5')).toBeTruthy()
188
+ })
189
+
190
+ it('re-fetches the initial board page when the filters change (reset)', async () => {
191
+ useMetadataCache.getState().setMetadata('issue', meta())
192
+ const api = fakeApi()
193
+ const { rerender } = render(
194
+ <ApiProvider client={api}>
195
+ <DynamicKanban model="issue" />
196
+ </ApiProvider>,
197
+ )
198
+ await screen.findByText('Backlog One')
199
+ const before = globalDataCalls(api).length
200
+
201
+ rerender(
202
+ <ApiProvider client={api}>
203
+ <DynamicKanban model="issue" defaultFilters={{ priority: 'high' }} />
204
+ </ApiProvider>,
205
+ )
206
+
207
+ // A fresh (un-scoped) board fetch fires — the per-lane pagination resets.
208
+ await waitFor(() => expect(globalDataCalls(api).length).toBeGreaterThan(before))
209
+ })
210
+ })
@@ -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,196 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // DynamicTable infinite-scroll (opt-in) behaviour:
4
+ // 1. With `infiniteScroll`, the classic pager is replaced by a "N de total"
5
+ // indicator; the first page loads with per_page=30 and the sentinel's
6
+ // intersection appends the next page, deduped by id.
7
+ // 2. Changing the filters resets the accumulated list back to page 1.
8
+ // 3. WITHOUT the prop (default), the classic pager renders and no sentinel /
9
+ // append path is engaged — existing hosts are untouched.
10
+ import { afterEach, beforeEach, 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 = {
17
+ t: (_k: string, o?: { defaultValue?: string; count?: number; total?: number }) => {
18
+ // Mirror the "{{count}} de {{total}}" interpolation the footer uses.
19
+ if (o?.defaultValue && o.count != null && o.total != null) {
20
+ return o.defaultValue.replace('{{count}}', String(o.count)).replace('{{total}}', String(o.total))
21
+ }
22
+ return o?.defaultValue ?? _k
23
+ },
24
+ i18n: { language: 'es' },
25
+ }
26
+ vi.mock('react-i18next', () => ({ useTranslation: () => I18N }))
27
+
28
+ import { DynamicTable } from '../dynamic-table'
29
+ import { ApiProvider, type ApiClient } from '../api-context'
30
+ import { useMetadataCache } from '../metadata-cache'
31
+ import type { TableMetadata } from '../types'
32
+
33
+ afterEach(cleanup)
34
+
35
+ function meta(): TableMetadata {
36
+ return {
37
+ title: 'Issues',
38
+ endpoint: '/data/issue',
39
+ group_by: 'stage',
40
+ columns: [
41
+ { key: 'title', label: 'Title', type: 'text', sortable: true, filterable: false, searchable: true },
42
+ ],
43
+ actions: [],
44
+ perPageOptions: [50],
45
+ defaultPerPage: 50,
46
+ searchPlaceholder: 'Buscar...',
47
+ enableCRUDActions: false,
48
+ hasActions: false,
49
+ }
50
+ }
51
+
52
+ // A model with `total` rows. The data endpoint returns a page of {id,title};
53
+ // page 2 deliberately re-includes the last id of page 1 to exercise dedup.
54
+ function fakeApi(total: number, pageSize = 30): ApiClient {
55
+ const ok = (data: unknown, extra: Record<string, unknown> = {}) => ({
56
+ data: { success: true, data, meta: { total, ...extra } },
57
+ })
58
+ const page = (n: number) => {
59
+ const start = (n - 1) * pageSize
60
+ const rows: Array<{ id: number; title: string }> = []
61
+ // Overlap: page n>1 repeats the previous page's last row (a server that
62
+ // re-paginated) — dedupeById must drop it.
63
+ const from = n > 1 ? start - 1 : start
64
+ for (let i = from; i < Math.min(start + pageSize, total); i++) {
65
+ rows.push({ id: i + 1, title: `Issue ${i + 1}` })
66
+ }
67
+ return rows
68
+ }
69
+ return {
70
+ get: vi.fn(async (url: string, cfg?: any) => {
71
+ if (url.startsWith('/metadata/table/')) return ok(meta())
72
+ if (url.endsWith('/facets')) return ok([])
73
+ // data endpoint
74
+ const p = cfg?.params?.page ?? 1
75
+ return ok(page(p))
76
+ }),
77
+ post: vi.fn(async () => ok(null)),
78
+ put: vi.fn(async () => ok(null)),
79
+ delete: vi.fn(async () => ok(null)),
80
+ }
81
+ }
82
+
83
+ function dataCalls(api: ApiClient) {
84
+ return (api.get as any).mock.calls.filter(
85
+ (c: any[]) => c[0] === '/data/issue' || (c[0] === undefined),
86
+ )
87
+ }
88
+
89
+ // Controllable IntersectionObserver — captures the observers so the test can
90
+ // fire the sentinel's intersection deterministically.
91
+ type IOEntry = { isIntersecting: boolean }
92
+ let observers: Array<{ fire: (v: boolean) => void }> = []
93
+ class FakeIO {
94
+ cb: (e: IOEntry[]) => void
95
+ constructor(cb: (e: IOEntry[]) => void) {
96
+ this.cb = cb
97
+ observers.push({ fire: (v: boolean) => this.cb([{ isIntersecting: v }]) })
98
+ }
99
+ observe() {}
100
+ disconnect() {}
101
+ }
102
+
103
+ describe('DynamicTable infinite scroll', () => {
104
+ beforeEach(() => {
105
+ observers = []
106
+ ;(globalThis as any).IntersectionObserver = FakeIO
107
+ })
108
+ afterEach(() => {
109
+ delete (globalThis as any).IntersectionObserver
110
+ })
111
+
112
+ it('loads page 1 (per_page 30) then appends page 2 on intersect, deduped', async () => {
113
+ useMetadataCache.getState().setMetadata('issue', meta())
114
+ const api = fakeApi(45) // 45 rows → page1: 30, page2: 15 (+1 overlap dropped)
115
+ render(
116
+ <ApiProvider client={api}>
117
+ <DynamicTable model="issue" infiniteScroll enableUrlSync={false} />
118
+ </ApiProvider>,
119
+ )
120
+
121
+ // Page 1 request: page=1, per_page=30.
122
+ await waitFor(() => {
123
+ const first = dataCalls(api)[0]
124
+ expect(first?.[1]?.params).toMatchObject({ page: 1, per_page: 30 })
125
+ })
126
+ // Footer indicator reflects the loaded/total counts.
127
+ await waitFor(() => expect(screen.getByText('30 de 45')).toBeTruthy())
128
+
129
+ // Sentinel enters view → page 2 fetched and APPENDED.
130
+ expect(observers.length).toBeGreaterThan(0)
131
+ observers.forEach((o) => o.fire(true))
132
+
133
+ await waitFor(() => {
134
+ const pages = dataCalls(api).map((c: any[]) => c[1]?.params?.page)
135
+ expect(pages).toContain(2)
136
+ })
137
+ // 30 + 15 unique = 45 (NOT 46 — the overlap row was deduped).
138
+ await waitFor(() => expect(screen.getByText('45 de 45')).toBeTruthy())
139
+ })
140
+
141
+ it('resets to page 1 (replacing the accumulated rows) when filters change', async () => {
142
+ useMetadataCache.getState().setMetadata('issue', meta())
143
+ const api = fakeApi(45)
144
+ const { rerender } = render(
145
+ <ApiProvider client={api}>
146
+ <DynamicTable model="issue" infiniteScroll enableUrlSync={false} />
147
+ </ApiProvider>,
148
+ )
149
+ await waitFor(() => expect(screen.getByText('30 de 45')).toBeTruthy())
150
+ observers.forEach((o) => o.fire(true))
151
+ await waitFor(() => expect(screen.getByText('45 de 45')).toBeTruthy())
152
+
153
+ // Change the active filters via defaultFilters (part of buildFilterParams).
154
+ rerender(
155
+ <ApiProvider client={api}>
156
+ <DynamicTable
157
+ model="issue"
158
+ infiniteScroll
159
+ enableUrlSync={false}
160
+ defaultFilters={{ stage: 'done' }}
161
+ />
162
+ </ApiProvider>,
163
+ )
164
+
165
+ // A fresh page-1 request carrying the new filter, and the list collapses
166
+ // back to a single page (30 of 45) — the accumulated 45 were dropped.
167
+ await waitFor(() => {
168
+ const withFilter = dataCalls(api).filter(
169
+ (c: any[]) => c[1]?.params?.f_stage === 'done',
170
+ )
171
+ expect(withFilter.some((c: any[]) => c[1]?.params?.page === 1)).toBe(true)
172
+ })
173
+ await waitFor(() => expect(screen.getByText('30 de 45')).toBeTruthy())
174
+ })
175
+
176
+ it('default (no prop) keeps the classic pager and never appends', async () => {
177
+ useMetadataCache.getState().setMetadata('issue', meta())
178
+ const api = fakeApi(45)
179
+ render(
180
+ <ApiProvider client={api}>
181
+ <DynamicTable model="issue" enableUrlSync={false} />
182
+ </ApiProvider>,
183
+ )
184
+ // Classic pager renders its rows-per-page control; the infinite footer
185
+ // indicator ("N de N") is absent.
186
+ await waitFor(() => expect(dataCalls(api).length).toBeGreaterThan(0))
187
+ expect(screen.queryByText('30 de 45')).toBeNull()
188
+ expect(screen.queryByText('45 de 45')).toBeNull()
189
+
190
+ // Firing any (stray) sentinel must NOT trigger a page-2 append.
191
+ observers.forEach((o) => o.fire(true))
192
+ await new Promise((r) => setTimeout(r, 50))
193
+ const pages = dataCalls(api).map((c: any[]) => c[1]?.params?.page)
194
+ expect(pages).not.toContain(2)
195
+ })
196
+ })
@@ -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
+ })