@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.
- package/CHANGELOG.md +79 -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 +35 -0
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +279 -36
- 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 +99 -5
- package/dist/use-facet-loaders.d.ts +38 -0
- package/dist/use-facet-loaders.d.ts.map +1 -0
- package/dist/use-facet-loaders.js +108 -0
- package/package.json +4 -4
- package/src/__tests__/dynamic-kanban.test.tsx +197 -0
- package/src/__tests__/use-dynamic-filters.test.tsx +243 -0
- package/src/dynamic-columns-shim.ts +9 -1
- package/src/dynamic-columns.tsx +1 -0
- package/src/dynamic-kanban.tsx +607 -100
- package/src/dynamic-table.tsx +52 -4
- package/src/use-dynamic-filters.ts +125 -5
- package/src/use-facet-loaders.ts +146 -0
|
@@ -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, prefetchFacets, facetOptions } = 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,29 @@ 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
|
+
// Prewarmed values (from prefetchFacets) → the popover opens with the
|
|
156
|
+
// list already there, no "Cargando…" flash.
|
|
157
|
+
options = facetOptions.get(f.column || f.key) ?? [];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
117
160
|
if (fType === 'select' && options.length === 0 && !f.searchEndpoint)
|
|
118
161
|
continue;
|
|
119
162
|
map.set(f.key, {
|
|
@@ -124,6 +167,7 @@ export function useDynamicFilters(metadata, opts = {}) {
|
|
|
124
167
|
onFilterChange: handleDynamicFilterChange,
|
|
125
168
|
loading: f.searchEndpoint ? !filterOptionsMap.has(f.searchEndpoint) : false,
|
|
126
169
|
searchEndpoint: f.searchEndpoint,
|
|
170
|
+
loadOptions,
|
|
127
171
|
});
|
|
128
172
|
}
|
|
129
173
|
for (const c of metadata.columns ?? []) {
|
|
@@ -132,8 +176,17 @@ export function useDynamicFilters(metadata, opts = {}) {
|
|
|
132
176
|
const hasStaticOptions = (c.options?.length ?? 0) > 0;
|
|
133
177
|
const hasEndpoint = !!c.searchEndpoint;
|
|
134
178
|
const isRelation = !!c.ref || c.filterType === 'dynamic_select';
|
|
179
|
+
// (A) A stage column with no options of its own inherits the pipeline's
|
|
180
|
+
// stages — so "Stage" becomes a colored select, not a text box.
|
|
181
|
+
const isStageColumn = c.key === groupBy &&
|
|
182
|
+
!hasStaticOptions &&
|
|
183
|
+
!hasEndpoint &&
|
|
184
|
+
!c.filterType &&
|
|
185
|
+
stageOptions.length > 0;
|
|
135
186
|
let filterType;
|
|
136
|
-
if (
|
|
187
|
+
if (isStageColumn)
|
|
188
|
+
filterType = 'select';
|
|
189
|
+
else if (c.filterType)
|
|
137
190
|
filterType = c.filterType;
|
|
138
191
|
else if (isRelation && hasEndpoint)
|
|
139
192
|
filterType = 'dynamic_select';
|
|
@@ -147,7 +200,7 @@ export function useDynamicFilters(metadata, opts = {}) {
|
|
|
147
200
|
filterType = 'date_range';
|
|
148
201
|
else
|
|
149
202
|
filterType = 'text';
|
|
150
|
-
|
|
203
|
+
let options = hasStaticOptions
|
|
151
204
|
? c.options.map((o) => ({
|
|
152
205
|
label: o.label,
|
|
153
206
|
value: String(o.value),
|
|
@@ -157,6 +210,20 @@ export function useDynamicFilters(metadata, opts = {}) {
|
|
|
157
210
|
: hasEndpoint && filterOptionsMap.has(c.searchEndpoint)
|
|
158
211
|
? filterOptionsMap.get(c.searchEndpoint) || []
|
|
159
212
|
: [];
|
|
213
|
+
if (isStageColumn)
|
|
214
|
+
options = stageOptions;
|
|
215
|
+
// (B) Upgrade a plain text filter to a facet value-picker (unless it's a
|
|
216
|
+
// long-text/body column — too many unique values to enumerate).
|
|
217
|
+
let loadOptions;
|
|
218
|
+
if (filterType === 'text' && facetsBase && !isLongTextColumn(c)) {
|
|
219
|
+
const loader = getFacetLoader(c.key);
|
|
220
|
+
if (loader) {
|
|
221
|
+
filterType = 'facet';
|
|
222
|
+
loadOptions = loader;
|
|
223
|
+
// Prewarmed values (from prefetchFacets) → instant open.
|
|
224
|
+
options = facetOptions.get(c.key) ?? [];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
160
227
|
map.set(c.key, {
|
|
161
228
|
filterType,
|
|
162
229
|
filterKey: c.key,
|
|
@@ -165,10 +232,37 @@ export function useDynamicFilters(metadata, opts = {}) {
|
|
|
165
232
|
onFilterChange: handleDynamicFilterChange,
|
|
166
233
|
loading: hasEndpoint && !filterOptionsMap.has(c.searchEndpoint),
|
|
167
234
|
searchEndpoint: c.searchEndpoint,
|
|
235
|
+
loadOptions,
|
|
168
236
|
});
|
|
169
237
|
}
|
|
170
238
|
return map;
|
|
171
|
-
}, [
|
|
239
|
+
}, [
|
|
240
|
+
metadata,
|
|
241
|
+
filterOptionsMap,
|
|
242
|
+
dynamicFilters,
|
|
243
|
+
handleDynamicFilterChange,
|
|
244
|
+
facetsBase,
|
|
245
|
+
getFacetLoader,
|
|
246
|
+
facetOptions,
|
|
247
|
+
]);
|
|
248
|
+
// Prewarm every facet field in one burst once the configs settle, so the
|
|
249
|
+
// Sheet popover and the lane value-picker open instantly (values + counts)
|
|
250
|
+
// instead of flashing "Cargando…". Deduped inside prefetchFacets by field set.
|
|
251
|
+
const facetFieldsSig = useMemo(() => {
|
|
252
|
+
const keys = [];
|
|
253
|
+
// Key on `filterKey` (the column the loader queries), not the config map key
|
|
254
|
+
// — they differ when an explicit filter sets a distinct `column`.
|
|
255
|
+
for (const config of columnFilterConfigs.values()) {
|
|
256
|
+
if (config.filterType === 'facet')
|
|
257
|
+
keys.push(config.filterKey);
|
|
258
|
+
}
|
|
259
|
+
return keys.join('|');
|
|
260
|
+
}, [columnFilterConfigs]);
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
if (!facetFieldsSig)
|
|
263
|
+
return;
|
|
264
|
+
prefetchFacets(facetFieldsSig.split('|'));
|
|
265
|
+
}, [facetFieldsSig, prefetchFacets]);
|
|
172
266
|
const searchableKeys = useMemo(() => (metadata ? getSearchableColumnKeys(metadata) : null), [metadata]);
|
|
173
267
|
// Serialize to `/data/:model` query params — identical operator encoding to
|
|
174
268
|
// DynamicTable.buildFilterParams (IN:/RANGE:/GTE:/LTE:/ILIKE:/plain).
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { FilterOption } from './dynamic-columns-shim';
|
|
2
|
+
export interface UseFacetLoadersResult {
|
|
3
|
+
/**
|
|
4
|
+
* Stable per-field loader → `<facetsBase>?field=&q=&limit=`. Cached per
|
|
5
|
+
* `field+query`. Null `facetsBase` → yields `undefined` (column stays text).
|
|
6
|
+
*/
|
|
7
|
+
getFacetLoader: (field: string) => ((q?: string) => Promise<FilterOption[]>) | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* Warms the caches for a set of facet fields in ONE parallel burst (called
|
|
10
|
+
* when metadata resolves), so a popover opens instantly with values + counts
|
|
11
|
+
* instead of showing "Cargando…". Deduped by the field set; a field whose
|
|
12
|
+
* request fails is simply omitted (it degrades to lazy/text — the rest are
|
|
13
|
+
* unaffected). Resolved options also land in `facetOptions`.
|
|
14
|
+
*/
|
|
15
|
+
prefetchFacets: (fields: string[]) => void;
|
|
16
|
+
/** Prefetched options per field (empty until `prefetchFacets` resolves). */
|
|
17
|
+
facetOptions: Map<string, FilterOption[]>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Long-text / body columns aren't worth faceting (thousands of unique values,
|
|
21
|
+
* each a paragraph). Heuristic on the metadata, not a hardcoded name list:
|
|
22
|
+
* a truncate-text/widget cell or a json/long-text SQL type are the strong
|
|
23
|
+
* signals; the name pattern is only a last-resort fallback.
|
|
24
|
+
*/
|
|
25
|
+
export declare function isLongTextColumn(c: {
|
|
26
|
+
key: string;
|
|
27
|
+
type?: string;
|
|
28
|
+
cellStyle?: string;
|
|
29
|
+
}): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Facet option machinery: a stable per-field lazy loader (`getFacetLoader`, with
|
|
32
|
+
* a `field+query` result cache) plus `prefetchFacets` to warm every facet
|
|
33
|
+
* field's values up front and `facetOptions` holding those prewarmed results.
|
|
34
|
+
* Loader identity is memoized per field (so it doesn't churn the config memo
|
|
35
|
+
* that depends on it). Caches reset when `facetsBase` changes (model switch).
|
|
36
|
+
*/
|
|
37
|
+
export declare function useFacetLoaders(facetsBase: string | null): UseFacetLoadersResult;
|
|
38
|
+
//# 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,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,cAAc,EAAE,CACd,KAAK,EAAE,MAAM,KACV,CAAC,CAAC,CAAC,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC,GAAG,SAAS,CAAA;IAC1D;;;;;;OAMG;IACH,cAAc,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,IAAI,CAAA;IAC1C,4EAA4E;IAC5E,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE,YAAY,EAAE,CAAC,CAAA;CAC1C;AAED;;;;;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;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,qBAAqB,CAwFhF"}
|
|
@@ -0,0 +1,108 @@
|
|
|
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, useState } 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
|
+
* Facet option machinery: a stable per-field lazy loader (`getFacetLoader`, with
|
|
25
|
+
* a `field+query` result cache) plus `prefetchFacets` to warm every facet
|
|
26
|
+
* field's values up front and `facetOptions` holding those prewarmed results.
|
|
27
|
+
* Loader identity is memoized per field (so it doesn't churn the config memo
|
|
28
|
+
* that depends on it). Caches reset when `facetsBase` changes (model switch).
|
|
29
|
+
*/
|
|
30
|
+
export function useFacetLoaders(facetsBase) {
|
|
31
|
+
const api = useApi();
|
|
32
|
+
const loaderCache = useRef(new Map());
|
|
33
|
+
const resultCache = useRef(new Map());
|
|
34
|
+
const prefetchSig = useRef(null);
|
|
35
|
+
const mountedRef = useRef(true);
|
|
36
|
+
const [facetOptions, setFacetOptions] = useState(new Map());
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
mountedRef.current = true;
|
|
39
|
+
return () => {
|
|
40
|
+
mountedRef.current = false;
|
|
41
|
+
};
|
|
42
|
+
}, []);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
loaderCache.current.clear();
|
|
45
|
+
resultCache.current.clear();
|
|
46
|
+
prefetchSig.current = null;
|
|
47
|
+
setFacetOptions(new Map());
|
|
48
|
+
}, [facetsBase]);
|
|
49
|
+
const getFacetLoader = useCallback((field) => {
|
|
50
|
+
if (!facetsBase)
|
|
51
|
+
return undefined;
|
|
52
|
+
const existing = loaderCache.current.get(field);
|
|
53
|
+
if (existing)
|
|
54
|
+
return existing;
|
|
55
|
+
const loader = async (q) => {
|
|
56
|
+
const term = q?.trim() || '';
|
|
57
|
+
const cacheKey = `${field}::${term}`;
|
|
58
|
+
const cached = resultCache.current.get(cacheKey);
|
|
59
|
+
if (cached)
|
|
60
|
+
return cached;
|
|
61
|
+
const res = await api.get(facetsBase, {
|
|
62
|
+
params: { field, q: term || undefined, limit: 50 },
|
|
63
|
+
});
|
|
64
|
+
const body = res.data;
|
|
65
|
+
const rows = body?.success ? body.data || [] : [];
|
|
66
|
+
const opts = rows.map((r) => ({
|
|
67
|
+
value: String(r.value ?? ''),
|
|
68
|
+
label: String(r.label ?? r.value ?? ''),
|
|
69
|
+
count: typeof r.count === 'number' ? r.count : undefined,
|
|
70
|
+
}));
|
|
71
|
+
resultCache.current.set(cacheKey, opts);
|
|
72
|
+
return opts;
|
|
73
|
+
};
|
|
74
|
+
loaderCache.current.set(field, loader);
|
|
75
|
+
return loader;
|
|
76
|
+
}, [api, facetsBase]);
|
|
77
|
+
const prefetchFacets = useCallback((fields) => {
|
|
78
|
+
if (!facetsBase || fields.length === 0)
|
|
79
|
+
return;
|
|
80
|
+
const sig = [...fields].sort().join('|');
|
|
81
|
+
if (prefetchSig.current === sig)
|
|
82
|
+
return;
|
|
83
|
+
prefetchSig.current = sig;
|
|
84
|
+
// One parallel burst; the loader seeds its own `${field}::` cache so the
|
|
85
|
+
// subsequent lazy open (q === '') is a cache hit. allSettled so one bad
|
|
86
|
+
// field never blocks the others.
|
|
87
|
+
void Promise.allSettled(fields.map(async (field) => {
|
|
88
|
+
const loader = getFacetLoader(field);
|
|
89
|
+
if (!loader)
|
|
90
|
+
return null;
|
|
91
|
+
const opts = await loader();
|
|
92
|
+
return { field, opts };
|
|
93
|
+
})).then((results) => {
|
|
94
|
+
if (!mountedRef.current)
|
|
95
|
+
return;
|
|
96
|
+
setFacetOptions((prev) => {
|
|
97
|
+
const next = new Map(prev);
|
|
98
|
+
for (const r of results) {
|
|
99
|
+
if (r.status === 'fulfilled' && r.value) {
|
|
100
|
+
next.set(r.value.field, r.value.opts);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return next;
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}, [facetsBase, getFacetLoader]);
|
|
107
|
+
return { getFacetLoader, prefetchFacets, facetOptions };
|
|
108
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "23.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.9.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependenciesMeta": {
|
|
44
44
|
"@tanstack/react-router": {
|
|
@@ -67,8 +67,8 @@
|
|
|
67
67
|
"typescript": "^6.0.0",
|
|
68
68
|
"vitest": "^4.0.0",
|
|
69
69
|
"zustand": "^5.0.0",
|
|
70
|
-
"@asteby/metacore-
|
|
71
|
-
"@asteby/metacore-
|
|
70
|
+
"@asteby/metacore-ui": "2.9.0",
|
|
71
|
+
"@asteby/metacore-sdk": "3.2.0"
|
|
72
72
|
},
|
|
73
73
|
"scripts": {
|
|
74
74
|
"build": "tsc -p tsconfig.json",
|
|
@@ -38,6 +38,10 @@ import {
|
|
|
38
38
|
isTransitionAllowed,
|
|
39
39
|
applyOptimisticMove,
|
|
40
40
|
selectCardColumns,
|
|
41
|
+
cardMatchesLaneQuery,
|
|
42
|
+
cardMatchesLaneFunnel,
|
|
43
|
+
translateOptionLabels,
|
|
44
|
+
summarizeFilterValues,
|
|
41
45
|
UNASSIGNED_LANE,
|
|
42
46
|
DynamicKanban,
|
|
43
47
|
} from '../dynamic-kanban'
|
|
@@ -208,6 +212,128 @@ describe('selectCardColumns', () => {
|
|
|
208
212
|
})
|
|
209
213
|
})
|
|
210
214
|
|
|
215
|
+
describe('cardMatchesLaneQuery', () => {
|
|
216
|
+
const cols = [
|
|
217
|
+
{ key: 'title', label: 'Title', type: 'text', sortable: false, filterable: false },
|
|
218
|
+
{ key: 'assignee', label: 'Assignee', type: 'text', sortable: false, filterable: false },
|
|
219
|
+
] as any
|
|
220
|
+
|
|
221
|
+
it('matches on the title (case-insensitive)', () => {
|
|
222
|
+
expect(cardMatchesLaneQuery(CARDS[0], cols, 'LOGIN')).toBe(true)
|
|
223
|
+
expect(cardMatchesLaneQuery(CARDS[0], cols, 'dark')).toBe(false)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('matches on any visible field value, not just the title', () => {
|
|
227
|
+
// card 1: title "Fix login bug", assignee "ana"
|
|
228
|
+
expect(cardMatchesLaneQuery(CARDS[0], cols, 'ana')).toBe(true)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('an empty query matches everything', () => {
|
|
232
|
+
expect(cardMatchesLaneQuery(CARDS[0], cols, '')).toBe(true)
|
|
233
|
+
expect(cardMatchesLaneQuery(CARDS[0], cols, ' ')).toBe(true)
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
describe('cardMatchesLaneFunnel', () => {
|
|
238
|
+
// card 4: title "Refactor api", assignee "ana", priority "mid", stage "in_progress"
|
|
239
|
+
const card = CARDS[3]
|
|
240
|
+
|
|
241
|
+
it('matches picked select/facet values by equality (IN), not substring', () => {
|
|
242
|
+
// exact value matches
|
|
243
|
+
expect(
|
|
244
|
+
cardMatchesLaneFunnel(card, { field: 'assignee', values: ['ana'] }),
|
|
245
|
+
).toBe(true)
|
|
246
|
+
// a substring of the value must NOT match under equality
|
|
247
|
+
expect(
|
|
248
|
+
cardMatchesLaneFunnel(card, { field: 'assignee', values: ['an'] }),
|
|
249
|
+
).toBe(false)
|
|
250
|
+
// IN semantics: any of the picked values
|
|
251
|
+
expect(
|
|
252
|
+
cardMatchesLaneFunnel(card, {
|
|
253
|
+
field: 'assignee',
|
|
254
|
+
values: ['dani', 'ana'],
|
|
255
|
+
}),
|
|
256
|
+
).toBe(true)
|
|
257
|
+
expect(
|
|
258
|
+
cardMatchesLaneFunnel(card, {
|
|
259
|
+
field: 'assignee',
|
|
260
|
+
values: ['dani', 'eva'],
|
|
261
|
+
}),
|
|
262
|
+
).toBe(false)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('matches free text by case-insensitive substring', () => {
|
|
266
|
+
expect(cardMatchesLaneFunnel(card, { field: 'title', text: 'refac' })).toBe(
|
|
267
|
+
true,
|
|
268
|
+
)
|
|
269
|
+
expect(cardMatchesLaneFunnel(card, { field: 'title', text: 'REFAC' })).toBe(
|
|
270
|
+
true,
|
|
271
|
+
)
|
|
272
|
+
expect(cardMatchesLaneFunnel(card, { field: 'title', text: 'zzz' })).toBe(
|
|
273
|
+
false,
|
|
274
|
+
)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('passes when there is no field or no criteria', () => {
|
|
278
|
+
expect(cardMatchesLaneFunnel(card, undefined)).toBe(true)
|
|
279
|
+
expect(cardMatchesLaneFunnel(card, { field: 'assignee' })).toBe(true)
|
|
280
|
+
expect(
|
|
281
|
+
cardMatchesLaneFunnel(card, { field: 'assignee', values: [] }),
|
|
282
|
+
).toBe(true)
|
|
283
|
+
})
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
describe('translateOptionLabels', () => {
|
|
287
|
+
it('runs option labels through the translator (stage i18n keys → localized)', () => {
|
|
288
|
+
const opts = [
|
|
289
|
+
{ value: 'backlog', label: 'issue.stage.backlog', color: 'slate' },
|
|
290
|
+
{ value: 'done', label: 'issue.stage.done', color: 'green' },
|
|
291
|
+
]
|
|
292
|
+
const dict: Record<string, string> = {
|
|
293
|
+
'issue.stage.backlog': 'Pendiente',
|
|
294
|
+
'issue.stage.done': 'Hecho',
|
|
295
|
+
}
|
|
296
|
+
const out = translateOptionLabels(opts, (k) => dict[k] ?? k)
|
|
297
|
+
expect(out.map((o) => o.label)).toEqual(['Pendiente', 'Hecho'])
|
|
298
|
+
// value + color preserved
|
|
299
|
+
expect(out[0]).toMatchObject({ value: 'backlog', color: 'slate' })
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('leaves raw values (no matching key) untouched', () => {
|
|
303
|
+
const out = translateOptionLabels(
|
|
304
|
+
[{ value: 'acme/repo', label: 'acme/repo' }],
|
|
305
|
+
(k) => k,
|
|
306
|
+
)
|
|
307
|
+
expect(out[0].label).toBe('acme/repo')
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
describe('summarizeFilterValues', () => {
|
|
312
|
+
const opts = [
|
|
313
|
+
{ value: 'backlog', label: 'Backlog' },
|
|
314
|
+
{ value: 'done', label: 'Done' },
|
|
315
|
+
{ value: 'review', label: 'Review' },
|
|
316
|
+
]
|
|
317
|
+
it('resolves a single value to its option label', () => {
|
|
318
|
+
expect(summarizeFilterValues(['done'], opts)).toBe('Done')
|
|
319
|
+
})
|
|
320
|
+
it('unwraps IN: and caps at 2 with a +n overflow', () => {
|
|
321
|
+
expect(summarizeFilterValues(['IN:backlog,done,review'], opts)).toBe(
|
|
322
|
+
'Backlog, Done +1',
|
|
323
|
+
)
|
|
324
|
+
})
|
|
325
|
+
it('renders a free-text ILIKE: match quoted', () => {
|
|
326
|
+
expect(summarizeFilterValues(['ILIKE:urgent'], opts)).toBe('"urgent"')
|
|
327
|
+
})
|
|
328
|
+
it('renders a numeric GTE/LTE range', () => {
|
|
329
|
+
expect(summarizeFilterValues(['GTE:10', 'LTE:20'], opts)).toBe('10 – 20')
|
|
330
|
+
})
|
|
331
|
+
it('is empty for no selection', () => {
|
|
332
|
+
expect(summarizeFilterValues([], opts)).toBe('')
|
|
333
|
+
expect(summarizeFilterValues(undefined, opts)).toBe('')
|
|
334
|
+
})
|
|
335
|
+
})
|
|
336
|
+
|
|
211
337
|
// ---------------------------------------------------------------------------
|
|
212
338
|
// 2. Render smoke + 3. optimistic PUT contract
|
|
213
339
|
// ---------------------------------------------------------------------------
|
|
@@ -332,3 +458,74 @@ describe('DynamicKanban filter bar', () => {
|
|
|
332
458
|
)
|
|
333
459
|
})
|
|
334
460
|
})
|
|
461
|
+
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
// 5. Per-lane search — the inline lane header search narrows ONLY that lane's
|
|
464
|
+
// already-fetched cards, client-side, by title + field values.
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
describe('DynamicKanban lane search', () => {
|
|
468
|
+
it('filters a single lane by card title, leaving the card set narrowed', async () => {
|
|
469
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
470
|
+
render(
|
|
471
|
+
<ApiProvider client={fakeApi()}>
|
|
472
|
+
<DynamicKanban model="issue" />
|
|
473
|
+
</ApiProvider>,
|
|
474
|
+
)
|
|
475
|
+
// all backlog cards present up front
|
|
476
|
+
expect(await screen.findByText('Fix login bug')).toBeTruthy()
|
|
477
|
+
expect(screen.getByText('Dark mode')).toBeTruthy()
|
|
478
|
+
|
|
479
|
+
// open the first lane's (backlog) inline search and type
|
|
480
|
+
const searchButtons = screen.getAllByLabelText('Buscar en la columna')
|
|
481
|
+
fireEvent.click(searchButtons[0])
|
|
482
|
+
const input = await screen.findByPlaceholderText('Buscar tarjetas...')
|
|
483
|
+
fireEvent.change(input, { target: { value: 'dark' } })
|
|
484
|
+
|
|
485
|
+
// backlog now shows only "Dark mode"; the other backlog cards are hidden
|
|
486
|
+
await waitFor(() => expect(screen.queryByText('Fix login bug')).toBeNull())
|
|
487
|
+
expect(screen.getByText('Dark mode')).toBeTruthy()
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
it('matches on a field value, not only the title', async () => {
|
|
491
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
492
|
+
render(
|
|
493
|
+
<ApiProvider client={fakeApi()}>
|
|
494
|
+
<DynamicKanban model="issue" />
|
|
495
|
+
</ApiProvider>,
|
|
496
|
+
)
|
|
497
|
+
expect(await screen.findByText('Fix login bug')).toBeTruthy()
|
|
498
|
+
const searchButtons = screen.getAllByLabelText('Buscar en la columna')
|
|
499
|
+
fireEvent.click(searchButtons[0])
|
|
500
|
+
const input = await screen.findByPlaceholderText('Buscar tarjetas...')
|
|
501
|
+
// card 1 (backlog) has assignee "ana"; card 2/3 do not.
|
|
502
|
+
fireEvent.change(input, { target: { value: 'ana' } })
|
|
503
|
+
await waitFor(() => expect(screen.queryByText('Dark mode')).toBeNull())
|
|
504
|
+
expect(screen.getByText('Fix login bug')).toBeTruthy()
|
|
505
|
+
})
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
// 6. Lane funnel — for a field with options (the Stage select), the value step
|
|
510
|
+
// renders the pro multi-select combobox, NOT a raw text input.
|
|
511
|
+
// ---------------------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
describe('DynamicKanban lane funnel', () => {
|
|
514
|
+
it('renders the value combobox for a field that has options', async () => {
|
|
515
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
516
|
+
render(
|
|
517
|
+
<ApiProvider client={fakeApi()}>
|
|
518
|
+
<DynamicKanban model="issue" />
|
|
519
|
+
</ApiProvider>,
|
|
520
|
+
)
|
|
521
|
+
await screen.findByText('Fix login bug')
|
|
522
|
+
// open the first lane's funnel (the only filterable field is Stage → select)
|
|
523
|
+
const funnelButtons = screen.getAllByLabelText('Filtrar columna')
|
|
524
|
+
fireEvent.click(funnelButtons[0])
|
|
525
|
+
// the value step is the searchable combobox, not the old raw text box
|
|
526
|
+
expect(
|
|
527
|
+
await screen.findByPlaceholderText('Buscar valores...'),
|
|
528
|
+
).toBeTruthy()
|
|
529
|
+
expect(screen.queryByPlaceholderText('Contiene...')).toBeNull()
|
|
530
|
+
})
|
|
531
|
+
})
|