@asteby/metacore-runtime-react 21.1.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 +46 -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 +211 -23
- 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 +96 -0
- 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 +496 -78
- package/src/dynamic-table.tsx +52 -4
- package/src/use-dynamic-filters.ts +100 -5
- package/src/use-facet-loaders.ts +79 -0
|
@@ -6,6 +6,25 @@ export interface UseDynamicFiltersOptions {
|
|
|
6
6
|
* Mirrors DynamicTable's `defaultFilters` — e.g. scoping a board to one owner.
|
|
7
7
|
*/
|
|
8
8
|
defaultFilters?: Record<string, any>;
|
|
9
|
+
/**
|
|
10
|
+
* Model key — the fallback base for the per-field `facets` endpoint when no
|
|
11
|
+
* `endpoint` is given. Omit all of `endpoint`/`model`/`facetsEndpoint` to keep
|
|
12
|
+
* text filters as bare "Contiene..." boxes.
|
|
13
|
+
*/
|
|
14
|
+
model?: string;
|
|
15
|
+
/**
|
|
16
|
+
* The org-scoped LIST endpoint (e.g. `/data/<model>/me`). The facets base is
|
|
17
|
+
* derived from it EXACTLY like DynamicTable derives the aggregate endpoint —
|
|
18
|
+
* `<endpoint>/facets` — so the board and its table sibling hit the same route
|
|
19
|
+
* (the backend registers both `/data/:model/facets` and
|
|
20
|
+
* `/data/:model/me/facets`).
|
|
21
|
+
*/
|
|
22
|
+
endpoint?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Explicit `facets` endpoint override — wins over `endpoint`/`model`. The
|
|
25
|
+
* loader appends `?field=<key>&q=<text>&limit=50`.
|
|
26
|
+
*/
|
|
27
|
+
facetsEndpoint?: string;
|
|
9
28
|
}
|
|
10
29
|
export interface UseDynamicFiltersResult {
|
|
11
30
|
/** Per-field selected values, `filterKey → values[]` (empty = inactive). */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-dynamic-filters.d.ts","sourceRoot":"","sources":["../src/use-dynamic-filters.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"use-dynamic-filters.d.ts","sourceRoot":"","sources":["../src/use-dynamic-filters.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,kBAAkB,EAAgB,MAAM,wBAAwB,CAAA;AAC9E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAE5C,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACpC;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,WAAW,uBAAuB;IACtC,4EAA4E;IAC5E,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAA;IACxC,sDAAsD;IACtD,YAAY,EAAE,MAAM,CAAA;IACpB,eAAe,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IACpC,iFAAiF;IACjF,mBAAmB,EAAE,GAAG,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAA;IACpD;;;;OAIG;IACH,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACjC,uDAAuD;IACvD,yBAAyB,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,IAAI,CAAA;IACxE,kEAAkE;IAClE,iBAAiB,EAAE,MAAM,CAAA;IACzB,oDAAoD;IACpD,QAAQ,EAAE,MAAM,IAAI,CAAA;CACrB;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,aAAa,GAAG,IAAI,EAC9B,IAAI,GAAE,wBAA6B,GAClC,uBAAuB,CAgSzB"}
|
|
@@ -14,6 +14,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
14
14
|
import { useApi } from './api-context';
|
|
15
15
|
import { DATE_CELL_TYPES } from './dynamic-columns';
|
|
16
16
|
import { getSearchableColumnKeys } from './column-visibility';
|
|
17
|
+
import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders';
|
|
17
18
|
/**
|
|
18
19
|
* Reads the model's filter metadata and returns the live filter state + the
|
|
19
20
|
* serialized request params. Pure of any view concern — DynamicTable adds its
|
|
@@ -21,11 +22,21 @@ import { getSearchableColumnKeys } from './column-visibility';
|
|
|
21
22
|
* straight onto its single-page fetch.
|
|
22
23
|
*/
|
|
23
24
|
export function useDynamicFilters(metadata, opts = {}) {
|
|
24
|
-
const { defaultFilters } = opts;
|
|
25
|
+
const { defaultFilters, model, endpoint, facetsEndpoint } = opts;
|
|
25
26
|
const api = useApi();
|
|
26
27
|
const [dynamicFilters, setDynamicFilters] = useState({});
|
|
27
28
|
const [globalFilter, setGlobalFilter] = useState('');
|
|
28
29
|
const [filterOptionsMap, setFilterOptionsMap] = useState(new Map());
|
|
30
|
+
// Where a `facet` filter loads its distinct values from. Explicit override
|
|
31
|
+
// wins; otherwise `<endpoint>/facets` (same derivation as the aggregate
|
|
32
|
+
// endpoint), falling back to the model. Null → text filters stay plain.
|
|
33
|
+
const facetsBase = facetsEndpoint ??
|
|
34
|
+
(endpoint
|
|
35
|
+
? `${endpoint}/facets`
|
|
36
|
+
: model
|
|
37
|
+
? `/data/${model}/facets`
|
|
38
|
+
: null);
|
|
39
|
+
const getFacetLoader = useFacetLoaders(facetsBase);
|
|
29
40
|
// Prefetch the option lists for relation/select filters (once per model). The
|
|
30
41
|
// combobox needs these before the user opens it; mirrors DynamicTable's
|
|
31
42
|
// metadata-init option prefetch, narrowed to the FILTER endpoints only (the
|
|
@@ -100,8 +111,17 @@ export function useDynamicFilters(metadata, opts = {}) {
|
|
|
100
111
|
const map = new Map();
|
|
101
112
|
if (!metadata)
|
|
102
113
|
return map;
|
|
114
|
+
const stages = metadata.stages ?? [];
|
|
115
|
+
const groupBy = metadata.group_by;
|
|
116
|
+
// Stage options derived once from the pipeline machine, reused wherever a
|
|
117
|
+
// stage column has no inline options of its own (A).
|
|
118
|
+
const stageOptions = stages.map((s) => ({
|
|
119
|
+
label: s.label,
|
|
120
|
+
value: s.key,
|
|
121
|
+
color: s.color,
|
|
122
|
+
}));
|
|
103
123
|
for (const f of metadata.filters ?? []) {
|
|
104
|
-
|
|
124
|
+
let fType = f.type;
|
|
105
125
|
let options = [];
|
|
106
126
|
if (f.options && f.options.length > 0) {
|
|
107
127
|
options = f.options.map((o) => ({
|
|
@@ -114,6 +134,26 @@ export function useDynamicFilters(metadata, opts = {}) {
|
|
|
114
134
|
if (f.searchEndpoint && filterOptionsMap.has(f.searchEndpoint)) {
|
|
115
135
|
options = filterOptionsMap.get(f.searchEndpoint) || [];
|
|
116
136
|
}
|
|
137
|
+
// (A) Stage column with no options of its own → project the pipeline
|
|
138
|
+
// stages (with their colors) into a real select instead of leaving it as
|
|
139
|
+
// a text box.
|
|
140
|
+
if (options.length === 0 &&
|
|
141
|
+
!f.searchEndpoint &&
|
|
142
|
+
(f.column || f.key) === groupBy &&
|
|
143
|
+
stageOptions.length > 0) {
|
|
144
|
+
fType = 'select';
|
|
145
|
+
options = stageOptions;
|
|
146
|
+
}
|
|
147
|
+
// (B) A plain text filter becomes a facet value-picker when a facets
|
|
148
|
+
// endpoint is available.
|
|
149
|
+
let loadOptions;
|
|
150
|
+
if (fType === 'text' && facetsBase) {
|
|
151
|
+
const loader = getFacetLoader(f.column || f.key);
|
|
152
|
+
if (loader) {
|
|
153
|
+
fType = 'facet';
|
|
154
|
+
loadOptions = loader;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
117
157
|
if (fType === 'select' && options.length === 0 && !f.searchEndpoint)
|
|
118
158
|
continue;
|
|
119
159
|
map.set(f.key, {
|
|
@@ -124,6 +164,7 @@ export function useDynamicFilters(metadata, opts = {}) {
|
|
|
124
164
|
onFilterChange: handleDynamicFilterChange,
|
|
125
165
|
loading: f.searchEndpoint ? !filterOptionsMap.has(f.searchEndpoint) : false,
|
|
126
166
|
searchEndpoint: f.searchEndpoint,
|
|
167
|
+
loadOptions,
|
|
127
168
|
});
|
|
128
169
|
}
|
|
129
170
|
for (const c of metadata.columns ?? []) {
|
|
@@ -132,8 +173,17 @@ export function useDynamicFilters(metadata, opts = {}) {
|
|
|
132
173
|
const hasStaticOptions = (c.options?.length ?? 0) > 0;
|
|
133
174
|
const hasEndpoint = !!c.searchEndpoint;
|
|
134
175
|
const isRelation = !!c.ref || c.filterType === 'dynamic_select';
|
|
176
|
+
// (A) A stage column with no options of its own inherits the pipeline's
|
|
177
|
+
// stages — so "Stage" becomes a colored select, not a text box.
|
|
178
|
+
const isStageColumn = c.key === groupBy &&
|
|
179
|
+
!hasStaticOptions &&
|
|
180
|
+
!hasEndpoint &&
|
|
181
|
+
!c.filterType &&
|
|
182
|
+
stageOptions.length > 0;
|
|
135
183
|
let filterType;
|
|
136
|
-
if (
|
|
184
|
+
if (isStageColumn)
|
|
185
|
+
filterType = 'select';
|
|
186
|
+
else if (c.filterType)
|
|
137
187
|
filterType = c.filterType;
|
|
138
188
|
else if (isRelation && hasEndpoint)
|
|
139
189
|
filterType = 'dynamic_select';
|
|
@@ -147,7 +197,7 @@ export function useDynamicFilters(metadata, opts = {}) {
|
|
|
147
197
|
filterType = 'date_range';
|
|
148
198
|
else
|
|
149
199
|
filterType = 'text';
|
|
150
|
-
|
|
200
|
+
let options = hasStaticOptions
|
|
151
201
|
? c.options.map((o) => ({
|
|
152
202
|
label: o.label,
|
|
153
203
|
value: String(o.value),
|
|
@@ -157,6 +207,18 @@ export function useDynamicFilters(metadata, opts = {}) {
|
|
|
157
207
|
: hasEndpoint && filterOptionsMap.has(c.searchEndpoint)
|
|
158
208
|
? filterOptionsMap.get(c.searchEndpoint) || []
|
|
159
209
|
: [];
|
|
210
|
+
if (isStageColumn)
|
|
211
|
+
options = stageOptions;
|
|
212
|
+
// (B) Upgrade a plain text filter to a facet value-picker (unless it's a
|
|
213
|
+
// long-text/body column — too many unique values to enumerate).
|
|
214
|
+
let loadOptions;
|
|
215
|
+
if (filterType === 'text' && facetsBase && !isLongTextColumn(c)) {
|
|
216
|
+
const loader = getFacetLoader(c.key);
|
|
217
|
+
if (loader) {
|
|
218
|
+
filterType = 'facet';
|
|
219
|
+
loadOptions = loader;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
160
222
|
map.set(c.key, {
|
|
161
223
|
filterType,
|
|
162
224
|
filterKey: c.key,
|
|
@@ -165,10 +227,18 @@ export function useDynamicFilters(metadata, opts = {}) {
|
|
|
165
227
|
onFilterChange: handleDynamicFilterChange,
|
|
166
228
|
loading: hasEndpoint && !filterOptionsMap.has(c.searchEndpoint),
|
|
167
229
|
searchEndpoint: c.searchEndpoint,
|
|
230
|
+
loadOptions,
|
|
168
231
|
});
|
|
169
232
|
}
|
|
170
233
|
return map;
|
|
171
|
-
}, [
|
|
234
|
+
}, [
|
|
235
|
+
metadata,
|
|
236
|
+
filterOptionsMap,
|
|
237
|
+
dynamicFilters,
|
|
238
|
+
handleDynamicFilterChange,
|
|
239
|
+
facetsBase,
|
|
240
|
+
getFacetLoader,
|
|
241
|
+
]);
|
|
172
242
|
const searchableKeys = useMemo(() => (metadata ? getSearchableColumnKeys(metadata) : null), [metadata]);
|
|
173
243
|
// Serialize to `/data/:model` query params — identical operator encoding to
|
|
174
244
|
// DynamicTable.buildFilterParams (IN:/RANGE:/GTE:/LTE:/ILIKE:/plain).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { FilterOption } from './dynamic-columns-shim';
|
|
2
|
+
/**
|
|
3
|
+
* Long-text / body columns aren't worth faceting (thousands of unique values,
|
|
4
|
+
* each a paragraph). Heuristic on the metadata, not a hardcoded name list:
|
|
5
|
+
* a truncate-text/widget cell or a json/long-text SQL type are the strong
|
|
6
|
+
* signals; the name pattern is only a last-resort fallback.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isLongTextColumn(c: {
|
|
9
|
+
key: string;
|
|
10
|
+
type?: string;
|
|
11
|
+
cellStyle?: string;
|
|
12
|
+
}): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Returns `getFacetLoader(field)` — a stable per-field loader that resolves the
|
|
15
|
+
* column's distinct values + counts from `<facetsBase>?field=&q=&limit=`. Loader
|
|
16
|
+
* identity is memoized per field (so it doesn't churn the config memo that
|
|
17
|
+
* depends on it) and results are cached per `field+query`. Both caches reset
|
|
18
|
+
* when `facetsBase` changes (model switch). A null `facetsBase` returns a
|
|
19
|
+
* factory that yields `undefined` — the caller keeps the column as plain text.
|
|
20
|
+
*/
|
|
21
|
+
export declare function useFacetLoaders(facetsBase: string | null): (field: string) => ((q?: string) => Promise<FilterOption[]>) | undefined;
|
|
22
|
+
//# sourceMappingURL=use-facet-loaders.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-facet-loaders.d.ts","sourceRoot":"","sources":["../src/use-facet-loaders.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAA;AAE1D;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE;IAClC,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB,GAAG,OAAO,CAQV;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,WAa7C,MAAM,WAVG,MAAM,KAAK,OAAO,CAAC,YAAY,EAAE,CAAC,cAqCtD"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// useFacetLoaders — the shared machinery that turns a plain text filter into a
|
|
2
|
+
// `facet` value-picker. Both DynamicKanban (via useDynamicFilters) and
|
|
3
|
+
// DynamicTable derive their filter configs independently, so the lazy
|
|
4
|
+
// per-field option loader + result cache lives here to guarantee they facet
|
|
5
|
+
// identically (one board and its table sibling hit the same `/facets` endpoint
|
|
6
|
+
// with the same caching).
|
|
7
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
8
|
+
import { useApi } from './api-context';
|
|
9
|
+
/**
|
|
10
|
+
* Long-text / body columns aren't worth faceting (thousands of unique values,
|
|
11
|
+
* each a paragraph). Heuristic on the metadata, not a hardcoded name list:
|
|
12
|
+
* a truncate-text/widget cell or a json/long-text SQL type are the strong
|
|
13
|
+
* signals; the name pattern is only a last-resort fallback.
|
|
14
|
+
*/
|
|
15
|
+
export function isLongTextColumn(c) {
|
|
16
|
+
if (c.cellStyle === 'truncate-text' || c.cellStyle === 'widget')
|
|
17
|
+
return true;
|
|
18
|
+
const t = (c.type || '').toLowerCase();
|
|
19
|
+
if (t === 'json' || t === 'long_text' || t === 'text_long' || t === 'widget')
|
|
20
|
+
return true;
|
|
21
|
+
return /^(body|description|content|notes?|comment|message|summary)$/i.test(c.key);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Returns `getFacetLoader(field)` — a stable per-field loader that resolves the
|
|
25
|
+
* column's distinct values + counts from `<facetsBase>?field=&q=&limit=`. Loader
|
|
26
|
+
* identity is memoized per field (so it doesn't churn the config memo that
|
|
27
|
+
* depends on it) and results are cached per `field+query`. Both caches reset
|
|
28
|
+
* when `facetsBase` changes (model switch). A null `facetsBase` returns a
|
|
29
|
+
* factory that yields `undefined` — the caller keeps the column as plain text.
|
|
30
|
+
*/
|
|
31
|
+
export function useFacetLoaders(facetsBase) {
|
|
32
|
+
const api = useApi();
|
|
33
|
+
const loaderCache = useRef(new Map());
|
|
34
|
+
const resultCache = useRef(new Map());
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
loaderCache.current.clear();
|
|
37
|
+
resultCache.current.clear();
|
|
38
|
+
}, [facetsBase]);
|
|
39
|
+
return useCallback((field) => {
|
|
40
|
+
if (!facetsBase)
|
|
41
|
+
return undefined;
|
|
42
|
+
const existing = loaderCache.current.get(field);
|
|
43
|
+
if (existing)
|
|
44
|
+
return existing;
|
|
45
|
+
const loader = async (q) => {
|
|
46
|
+
const term = q?.trim() || '';
|
|
47
|
+
const cacheKey = `${field}::${term}`;
|
|
48
|
+
const cached = resultCache.current.get(cacheKey);
|
|
49
|
+
if (cached)
|
|
50
|
+
return cached;
|
|
51
|
+
const res = await api.get(facetsBase, {
|
|
52
|
+
params: { field, q: term || undefined, limit: 50 },
|
|
53
|
+
});
|
|
54
|
+
const body = res.data;
|
|
55
|
+
const rows = body?.success ? body.data || [] : [];
|
|
56
|
+
const opts = rows.map((r) => ({
|
|
57
|
+
value: String(r.value ?? ''),
|
|
58
|
+
label: String(r.label ?? r.value ?? ''),
|
|
59
|
+
count: typeof r.count === 'number' ? r.count : undefined,
|
|
60
|
+
}));
|
|
61
|
+
resultCache.current.set(cacheKey, opts);
|
|
62
|
+
return opts;
|
|
63
|
+
};
|
|
64
|
+
loaderCache.current.set(field, loader);
|
|
65
|
+
return loader;
|
|
66
|
+
}, [api, facetsBase]);
|
|
67
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "22.0.0",
|
|
4
4
|
"description": "React runtime for metacore hosts — renders addon contributions dynamically",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"sonner": ">=1.7",
|
|
39
39
|
"zustand": ">=5",
|
|
40
40
|
"@asteby/metacore-sdk": "^3.2.0",
|
|
41
|
-
"@asteby/metacore-ui": "^2.
|
|
41
|
+
"@asteby/metacore-ui": "^2.8.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependenciesMeta": {
|
|
44
44
|
"@tanstack/react-router": {
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"vitest": "^4.0.0",
|
|
69
69
|
"zustand": "^5.0.0",
|
|
70
70
|
"@asteby/metacore-sdk": "3.2.0",
|
|
71
|
-
"@asteby/metacore-ui": "2.
|
|
71
|
+
"@asteby/metacore-ui": "2.8.0"
|
|
72
72
|
},
|
|
73
73
|
"scripts": {
|
|
74
74
|
"build": "tsc -p tsconfig.json",
|
|
@@ -38,6 +38,8 @@ import {
|
|
|
38
38
|
isTransitionAllowed,
|
|
39
39
|
applyOptimisticMove,
|
|
40
40
|
selectCardColumns,
|
|
41
|
+
cardMatchesLaneQuery,
|
|
42
|
+
summarizeFilterValues,
|
|
41
43
|
UNASSIGNED_LANE,
|
|
42
44
|
DynamicKanban,
|
|
43
45
|
} from '../dynamic-kanban'
|
|
@@ -208,6 +210,54 @@ describe('selectCardColumns', () => {
|
|
|
208
210
|
})
|
|
209
211
|
})
|
|
210
212
|
|
|
213
|
+
describe('cardMatchesLaneQuery', () => {
|
|
214
|
+
const cols = [
|
|
215
|
+
{ key: 'title', label: 'Title', type: 'text', sortable: false, filterable: false },
|
|
216
|
+
{ key: 'assignee', label: 'Assignee', type: 'text', sortable: false, filterable: false },
|
|
217
|
+
] as any
|
|
218
|
+
|
|
219
|
+
it('matches on the title (case-insensitive)', () => {
|
|
220
|
+
expect(cardMatchesLaneQuery(CARDS[0], cols, 'LOGIN')).toBe(true)
|
|
221
|
+
expect(cardMatchesLaneQuery(CARDS[0], cols, 'dark')).toBe(false)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('matches on any visible field value, not just the title', () => {
|
|
225
|
+
// card 1: title "Fix login bug", assignee "ana"
|
|
226
|
+
expect(cardMatchesLaneQuery(CARDS[0], cols, 'ana')).toBe(true)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('an empty query matches everything', () => {
|
|
230
|
+
expect(cardMatchesLaneQuery(CARDS[0], cols, '')).toBe(true)
|
|
231
|
+
expect(cardMatchesLaneQuery(CARDS[0], cols, ' ')).toBe(true)
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
describe('summarizeFilterValues', () => {
|
|
236
|
+
const opts = [
|
|
237
|
+
{ value: 'backlog', label: 'Backlog' },
|
|
238
|
+
{ value: 'done', label: 'Done' },
|
|
239
|
+
{ value: 'review', label: 'Review' },
|
|
240
|
+
]
|
|
241
|
+
it('resolves a single value to its option label', () => {
|
|
242
|
+
expect(summarizeFilterValues(['done'], opts)).toBe('Done')
|
|
243
|
+
})
|
|
244
|
+
it('unwraps IN: and caps at 2 with a +n overflow', () => {
|
|
245
|
+
expect(summarizeFilterValues(['IN:backlog,done,review'], opts)).toBe(
|
|
246
|
+
'Backlog, Done +1',
|
|
247
|
+
)
|
|
248
|
+
})
|
|
249
|
+
it('renders a free-text ILIKE: match quoted', () => {
|
|
250
|
+
expect(summarizeFilterValues(['ILIKE:urgent'], opts)).toBe('"urgent"')
|
|
251
|
+
})
|
|
252
|
+
it('renders a numeric GTE/LTE range', () => {
|
|
253
|
+
expect(summarizeFilterValues(['GTE:10', 'LTE:20'], opts)).toBe('10 – 20')
|
|
254
|
+
})
|
|
255
|
+
it('is empty for no selection', () => {
|
|
256
|
+
expect(summarizeFilterValues([], opts)).toBe('')
|
|
257
|
+
expect(summarizeFilterValues(undefined, opts)).toBe('')
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
211
261
|
// ---------------------------------------------------------------------------
|
|
212
262
|
// 2. Render smoke + 3. optimistic PUT contract
|
|
213
263
|
// ---------------------------------------------------------------------------
|
|
@@ -332,3 +382,49 @@ describe('DynamicKanban filter bar', () => {
|
|
|
332
382
|
)
|
|
333
383
|
})
|
|
334
384
|
})
|
|
385
|
+
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// 5. Per-lane search — the inline lane header search narrows ONLY that lane's
|
|
388
|
+
// already-fetched cards, client-side, by title + field values.
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
describe('DynamicKanban lane search', () => {
|
|
392
|
+
it('filters a single lane by card title, leaving the card set narrowed', async () => {
|
|
393
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
394
|
+
render(
|
|
395
|
+
<ApiProvider client={fakeApi()}>
|
|
396
|
+
<DynamicKanban model="issue" />
|
|
397
|
+
</ApiProvider>,
|
|
398
|
+
)
|
|
399
|
+
// all backlog cards present up front
|
|
400
|
+
expect(await screen.findByText('Fix login bug')).toBeTruthy()
|
|
401
|
+
expect(screen.getByText('Dark mode')).toBeTruthy()
|
|
402
|
+
|
|
403
|
+
// open the first lane's (backlog) inline search and type
|
|
404
|
+
const searchButtons = screen.getAllByLabelText('Buscar en la columna')
|
|
405
|
+
fireEvent.click(searchButtons[0])
|
|
406
|
+
const input = await screen.findByPlaceholderText('Buscar tarjetas...')
|
|
407
|
+
fireEvent.change(input, { target: { value: 'dark' } })
|
|
408
|
+
|
|
409
|
+
// backlog now shows only "Dark mode"; the other backlog cards are hidden
|
|
410
|
+
await waitFor(() => expect(screen.queryByText('Fix login bug')).toBeNull())
|
|
411
|
+
expect(screen.getByText('Dark mode')).toBeTruthy()
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
it('matches on a field value, not only the title', async () => {
|
|
415
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
416
|
+
render(
|
|
417
|
+
<ApiProvider client={fakeApi()}>
|
|
418
|
+
<DynamicKanban model="issue" />
|
|
419
|
+
</ApiProvider>,
|
|
420
|
+
)
|
|
421
|
+
expect(await screen.findByText('Fix login bug')).toBeTruthy()
|
|
422
|
+
const searchButtons = screen.getAllByLabelText('Buscar en la columna')
|
|
423
|
+
fireEvent.click(searchButtons[0])
|
|
424
|
+
const input = await screen.findByPlaceholderText('Buscar tarjetas...')
|
|
425
|
+
// card 1 (backlog) has assignee "ana"; card 2/3 do not.
|
|
426
|
+
fireEvent.change(input, { target: { value: 'ana' } })
|
|
427
|
+
await waitFor(() => expect(screen.queryByText('Dark mode')).toBeNull())
|
|
428
|
+
expect(screen.getByText('Fix login bug')).toBeTruthy()
|
|
429
|
+
})
|
|
430
|
+
})
|
|
@@ -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
|
}
|