@asteby/metacore-runtime-react 21.0.0 → 22.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,182 @@
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 load = result.current.columnFilterConfigs.get('title')!.loadOptions!
173
+ await load('')
174
+ await load('')
175
+ // Second call served from cache → only one network hit for that key.
176
+ await waitFor(() =>
177
+ expect((api.get as any).mock.calls.filter((c: any[]) =>
178
+ String(c[0]).endsWith('/facets'),
179
+ ).length).toBe(1),
180
+ )
181
+ })
182
+ })
@@ -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
  }