@asteby/metacore-runtime-react 21.1.0 → 23.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,243 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // useDynamicFilters — the metadata-driven filter engine. Coverage for the
4
+ // "pro filters" upgrades:
5
+ // A. A stage column with no options of its own inherits the pipeline stages
6
+ // (colored) as a real `select` instead of falling back to a text box.
7
+ // B. A plain text filterable column becomes a `facet` value-picker when a
8
+ // facets endpoint is derivable (model), with a `loadOptions` loader that
9
+ // hits `/data/<model>/facets`; long-text/body columns and the
10
+ // no-model case stay a plain `text` filter (the graceful fallback).
11
+ import { describe, expect, it, vi } from 'vitest'
12
+ import { renderHook, waitFor } from '@testing-library/react'
13
+ import * as React from 'react'
14
+ import { useDynamicFilters } from '../use-dynamic-filters'
15
+ import { ApiProvider, type ApiClient } from '../api-context'
16
+ import type { TableMetadata } from '../types'
17
+
18
+ const STAGES = [
19
+ { key: 'backlog', label: 'Backlog', color: 'slate', order: 0 },
20
+ { key: 'in_progress', label: 'In Progress', color: 'blue', order: 1 },
21
+ { key: 'done', label: 'Done', color: 'green', order: 2 },
22
+ ]
23
+
24
+ function meta(over: Partial<TableMetadata> = {}): TableMetadata {
25
+ return {
26
+ title: 'Issues',
27
+ endpoint: '/data/issue',
28
+ view_type: 'kanban',
29
+ group_by: 'stage',
30
+ stages: STAGES,
31
+ columns: [
32
+ { key: 'title', label: 'Title', type: 'text', sortable: true, filterable: true, searchable: true },
33
+ { key: 'body', label: 'Body', type: 'text', sortable: false, filterable: true, cellStyle: 'truncate-text' },
34
+ {
35
+ key: 'stage',
36
+ label: 'Stage',
37
+ type: 'status',
38
+ sortable: false,
39
+ filterable: true,
40
+ },
41
+ ],
42
+ actions: [],
43
+ perPageOptions: [50],
44
+ defaultPerPage: 50,
45
+ searchPlaceholder: 'Buscar...',
46
+ enableCRUDActions: true,
47
+ hasActions: false,
48
+ ...over,
49
+ }
50
+ }
51
+
52
+ function fakeApi(over: Partial<ApiClient> = {}): ApiClient {
53
+ return {
54
+ get: vi.fn(async () => ({
55
+ data: {
56
+ success: true,
57
+ data: [
58
+ { value: 'ana', label: 'Ana', count: 3 },
59
+ { value: 'beto', label: 'Beto', count: 1 },
60
+ ],
61
+ },
62
+ })),
63
+ post: vi.fn(async () => ({ data: { success: true, data: null } })),
64
+ put: vi.fn(async () => ({ data: { success: true, data: null } })),
65
+ delete: vi.fn(async () => ({ data: { success: true, data: null } })),
66
+ ...over,
67
+ }
68
+ }
69
+
70
+ function wrapper(api: ApiClient) {
71
+ return ({ children }: { children: React.ReactNode }) => (
72
+ <ApiProvider client={api}>{children}</ApiProvider>
73
+ )
74
+ }
75
+
76
+ describe('useDynamicFilters — stage as select (A)', () => {
77
+ it('projects the pipeline stages (with colors) onto the stage column', () => {
78
+ const { result } = renderHook(
79
+ () => useDynamicFilters(meta(), { model: 'issue' }),
80
+ { wrapper: wrapper(fakeApi()) },
81
+ )
82
+ const cfg = result.current.columnFilterConfigs.get('stage')
83
+ expect(cfg?.filterType).toBe('select')
84
+ expect(cfg?.options.map((o) => o.value)).toEqual([
85
+ 'backlog',
86
+ 'in_progress',
87
+ 'done',
88
+ ])
89
+ expect(cfg?.options.map((o) => o.color)).toEqual(['slate', 'blue', 'green'])
90
+ })
91
+
92
+ it('leaves the stage column as text when there are no stages', () => {
93
+ const { result } = renderHook(
94
+ () => useDynamicFilters(meta({ stages: [] }), { model: 'issue' }),
95
+ { wrapper: wrapper(fakeApi()) },
96
+ )
97
+ // No stages and no options → text (upgraded to facet since a model is set).
98
+ expect(result.current.columnFilterConfigs.get('stage')?.filterType).toBe(
99
+ 'facet',
100
+ )
101
+ })
102
+ })
103
+
104
+ describe('useDynamicFilters — facet upgrade + fallback (B)', () => {
105
+ it('upgrades a plain text column to a facet with a working loader', async () => {
106
+ const api = fakeApi()
107
+ const { result } = renderHook(
108
+ () => useDynamicFilters(meta(), { model: 'issue' }),
109
+ { wrapper: wrapper(api) },
110
+ )
111
+ const cfg = result.current.columnFilterConfigs.get('title')
112
+ expect(cfg?.filterType).toBe('facet')
113
+ expect(typeof cfg?.loadOptions).toBe('function')
114
+
115
+ const opts = await cfg!.loadOptions!('an')
116
+ expect(api.get).toHaveBeenCalledWith(
117
+ '/data/issue/facets',
118
+ expect.objectContaining({
119
+ params: expect.objectContaining({ field: 'title', q: 'an', limit: 50 }),
120
+ }),
121
+ )
122
+ expect(opts).toEqual([
123
+ { value: 'ana', label: 'Ana', count: 3 },
124
+ { value: 'beto', label: 'Beto', count: 1 },
125
+ ])
126
+ })
127
+
128
+ it('derives the facets URL from the list endpoint (like the aggregate endpoint)', async () => {
129
+ const api = fakeApi()
130
+ const { result } = renderHook(
131
+ () =>
132
+ useDynamicFilters(meta(), {
133
+ model: 'issue',
134
+ endpoint: '/data/issue/me',
135
+ }),
136
+ { wrapper: wrapper(api) },
137
+ )
138
+ await result.current.columnFilterConfigs.get('title')!.loadOptions!()
139
+ expect(api.get).toHaveBeenCalledWith(
140
+ '/data/issue/me/facets',
141
+ expect.objectContaining({
142
+ params: expect.objectContaining({ field: 'title', limit: 50 }),
143
+ }),
144
+ )
145
+ })
146
+
147
+ it('keeps long-text/body columns as a plain text filter (no faceting)', () => {
148
+ const { result } = renderHook(
149
+ () => useDynamicFilters(meta(), { model: 'issue' }),
150
+ { wrapper: wrapper(fakeApi()) },
151
+ )
152
+ const cfg = result.current.columnFilterConfigs.get('body')
153
+ expect(cfg?.filterType).toBe('text')
154
+ expect(cfg?.loadOptions).toBeUndefined()
155
+ })
156
+
157
+ it('falls back to a plain text filter when no facets endpoint is derivable', () => {
158
+ const { result } = renderHook(() => useDynamicFilters(meta()), {
159
+ wrapper: wrapper(fakeApi()),
160
+ })
161
+ const cfg = result.current.columnFilterConfigs.get('title')
162
+ expect(cfg?.filterType).toBe('text')
163
+ expect(cfg?.loadOptions).toBeUndefined()
164
+ })
165
+
166
+ it('caches loader results per field+query (no refetch on reopen)', async () => {
167
+ const api = fakeApi()
168
+ const { result } = renderHook(
169
+ () => useDynamicFilters(meta(), { model: 'issue' }),
170
+ { wrapper: wrapper(api) },
171
+ )
172
+ const titleFacetCalls = () =>
173
+ (api.get as any).mock.calls.filter(
174
+ (c: any[]) =>
175
+ String(c[0]).endsWith('/facets') &&
176
+ c[1]?.params?.field === 'title' &&
177
+ !c[1]?.params?.q,
178
+ ).length
179
+ // Let the prewarm settle first, then reopening (empty query) is cached.
180
+ await waitFor(() => expect(titleFacetCalls()).toBe(1))
181
+ const load = result.current.columnFilterConfigs.get('title')!.loadOptions!
182
+ await load('')
183
+ await load('')
184
+ expect(titleFacetCalls()).toBe(1)
185
+ })
186
+ })
187
+
188
+ describe('useDynamicFilters — facet prefetch', () => {
189
+ it('prewarms facet options on mount so the config carries them (no lazy open)', async () => {
190
+ const api = fakeApi()
191
+ const { result } = renderHook(
192
+ () => useDynamicFilters(meta(), { model: 'issue' }),
193
+ { wrapper: wrapper(api) },
194
+ )
195
+ // The prefetch burst fires an empty-query request per facet field.
196
+ await waitFor(() =>
197
+ expect(
198
+ (api.get as any).mock.calls.some(
199
+ (c: any[]) =>
200
+ String(c[0]) === '/data/issue/facets' &&
201
+ c[1]?.params?.field === 'title' &&
202
+ c[1]?.params?.q === undefined,
203
+ ),
204
+ ).toBe(true),
205
+ )
206
+ // …and those values land directly on the config (popover opens instantly).
207
+ await waitFor(() =>
208
+ expect(
209
+ result.current.columnFilterConfigs.get('title')?.options,
210
+ ).toEqual([
211
+ { value: 'ana', label: 'Ana', count: 3 },
212
+ { value: 'beto', label: 'Beto', count: 1 },
213
+ ]),
214
+ )
215
+ })
216
+
217
+ it('opening a prewarmed facet is a cache hit (no extra request)', async () => {
218
+ const api = fakeApi()
219
+ const { result } = renderHook(
220
+ () => useDynamicFilters(meta(), { model: 'issue' }),
221
+ { wrapper: wrapper(api) },
222
+ )
223
+ // wait for prefetch to settle
224
+ await waitFor(() =>
225
+ expect(
226
+ result.current.columnFilterConfigs.get('title')?.options?.length,
227
+ ).toBe(2),
228
+ )
229
+ const before = (api.get as any).mock.calls.filter(
230
+ (c: any[]) =>
231
+ String(c[0]).endsWith('/facets') &&
232
+ c[1]?.params?.field === 'title',
233
+ ).length
234
+ // the popover's empty-query load reuses the prewarmed cache
235
+ await result.current.columnFilterConfigs.get('title')!.loadOptions!('')
236
+ const after = (api.get as any).mock.calls.filter(
237
+ (c: any[]) =>
238
+ String(c[0]).endsWith('/facets') &&
239
+ c[1]?.params?.field === 'title',
240
+ ).length
241
+ expect(after).toBe(before)
242
+ })
243
+ })
@@ -11,16 +11,24 @@ export interface FilterOption {
11
11
  value: string
12
12
  icon?: string
13
13
  color?: string
14
+ /** Occurrence count for `facet` options (rendered muted, right-aligned). */
15
+ count?: number
14
16
  }
15
17
 
16
18
  export interface ColumnFilterConfig {
17
- filterType: 'select' | 'boolean' | 'date_range' | 'number_range' | 'text' | string
19
+ filterType: 'select' | 'boolean' | 'date_range' | 'number_range' | 'text' | 'facet' | string
18
20
  filterKey: string
19
21
  options: FilterOption[]
20
22
  selectedValues: string[]
21
23
  onFilterChange: (filterKey: string, values: string[]) => void
22
24
  loading?: boolean
23
25
  searchEndpoint?: string
26
+ /**
27
+ * Lazy option loader for `facet` filters — resolves the column's distinct
28
+ * values + counts from the `/facets` endpoint. Passed straight through to
29
+ * `ColumnFilterControl.loadOptions`.
30
+ */
31
+ loadOptions?: (q?: string) => Promise<FilterOption[]>
24
32
  }
25
33
 
26
34
  /** Signature for the host-provided `getDynamicColumns` factory. */
@@ -757,6 +757,7 @@ export function makeDefaultGetDynamicColumns(
757
757
  filterSearchEndpoint: filterConfig.searchEndpoint,
758
758
  selectedValues: filterConfig.selectedValues,
759
759
  onFilterChange: filterConfig.onFilterChange,
760
+ loadOptions: filterConfig.loadOptions,
760
761
  }
761
762
  Object.assign(columnMeta, fm)
762
763
  }