@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.
- package/CHANGELOG.md +59 -0
- package/dist/dynamic-columns-shim.d.ts +9 -1
- package/dist/dynamic-columns-shim.d.ts.map +1 -1
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +1 -0
- package/dist/dynamic-kanban.d.ts +16 -0
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +265 -11
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +51 -4
- package/dist/use-dynamic-filters.d.ts +19 -0
- package/dist/use-dynamic-filters.d.ts.map +1 -1
- package/dist/use-dynamic-filters.js +75 -5
- package/dist/use-facet-loaders.d.ts +22 -0
- package/dist/use-facet-loaders.d.ts.map +1 -0
- package/dist/use-facet-loaders.js +67 -0
- package/package.json +3 -3
- package/src/__tests__/dynamic-kanban.test.tsx +106 -5
- package/src/__tests__/use-dynamic-filters.test.tsx +182 -0
- package/src/dynamic-columns-shim.ts +9 -1
- package/src/dynamic-columns.tsx +1 -0
- package/src/dynamic-kanban.tsx +676 -36
- package/src/dynamic-table.tsx +52 -4
- package/src/use-dynamic-filters.ts +100 -5
- package/src/use-facet-loaders.ts +79 -0
|
@@ -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. */
|
package/src/dynamic-columns.tsx
CHANGED
|
@@ -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
|
}
|