@asteby/metacore-runtime-react 22.0.0 → 23.1.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 +62 -0
- package/dist/dynamic-kanban.d.ts +20 -8
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +103 -102
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +60 -7
- package/dist/filter-chips.d.ts +60 -0
- package/dist/filter-chips.d.ts.map +1 -0
- package/dist/filter-chips.js +93 -0
- package/dist/use-dynamic-filters.d.ts.map +1 -1
- package/dist/use-dynamic-filters.js +25 -1
- package/dist/use-facet-loaders.d.ts +23 -7
- package/dist/use-facet-loaders.d.ts.map +1 -1
- package/dist/use-facet-loaders.js +49 -8
- package/package.json +3 -3
- package/src/__tests__/dynamic-kanban.test.tsx +141 -0
- package/src/__tests__/dynamic-table-filters.test.tsx +102 -0
- package/src/__tests__/filter-chips.test.tsx +119 -0
- package/src/__tests__/use-dynamic-filters.test.tsx +65 -4
- package/src/dynamic-kanban.tsx +189 -199
- package/src/dynamic-table.tsx +69 -6
- package/src/filter-chips.tsx +175 -0
- package/src/use-dynamic-filters.ts +26 -1
- package/src/use-facet-loaders.ts +76 -9
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable summary of a field's selected filter values for a chip / row.
|
|
3
|
+
* Resolves option labels, unwraps the wire operators
|
|
4
|
+
* (`IN:`/`ILIKE:`/`RANGE:`/`GTE:`/`LTE:`/date `from_to`), and caps the list at
|
|
5
|
+
* `maxShown` values with a `+n` overflow. Pure — exported for unit tests.
|
|
6
|
+
*/
|
|
7
|
+
export declare function summarizeFilterValues(values: string[] | undefined, options: {
|
|
8
|
+
label: string;
|
|
9
|
+
value: string;
|
|
10
|
+
}[] | undefined, maxShown?: number): string;
|
|
11
|
+
/**
|
|
12
|
+
* The color of a filter's first selected value (for the chip's dot) — e.g. a
|
|
13
|
+
* stage's palette color. Returns a resolved CSS color, or undefined for
|
|
14
|
+
* operator/range/free-text values that carry no option color.
|
|
15
|
+
*/
|
|
16
|
+
export declare function chipValueColor(config: {
|
|
17
|
+
selectedValues: string[];
|
|
18
|
+
options: {
|
|
19
|
+
value: string;
|
|
20
|
+
color?: string;
|
|
21
|
+
}[];
|
|
22
|
+
}): string | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Translates option labels through the app translator (manifest i18n keys →
|
|
25
|
+
* localized text). A raw value with no matching key falls through to itself via
|
|
26
|
+
* `defaultValue`. Pure — exported for unit tests.
|
|
27
|
+
*/
|
|
28
|
+
export declare function translateOptionLabels<T extends {
|
|
29
|
+
label: string;
|
|
30
|
+
}>(options: T[], translate: (key: string) => string): T[];
|
|
31
|
+
export interface FilterChipField {
|
|
32
|
+
key: string;
|
|
33
|
+
label: string;
|
|
34
|
+
config: {
|
|
35
|
+
selectedValues: string[];
|
|
36
|
+
options: {
|
|
37
|
+
label: string;
|
|
38
|
+
value: string;
|
|
39
|
+
color?: string;
|
|
40
|
+
}[];
|
|
41
|
+
filterKey: string;
|
|
42
|
+
onFilterChange: (filterKey: string, values: string[]) => void;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export interface FilterChipsRowProps {
|
|
46
|
+
/** Fields with an active selection (one chip each). */
|
|
47
|
+
fields: FilterChipField[];
|
|
48
|
+
/** Clears every filter (the trailing "Limpiar todo"). */
|
|
49
|
+
onClearAll: () => void;
|
|
50
|
+
className?: string;
|
|
51
|
+
'data-testid'?: string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The removable active-filter chip row shown under a board/table toolbar. Each
|
|
55
|
+
* chip: an optional value-color dot, "Campo: valor(es)" (capped + `+n`), and an
|
|
56
|
+
* X that clears that field; a trailing "Limpiar todo". Renders nothing when no
|
|
57
|
+
* field is active.
|
|
58
|
+
*/
|
|
59
|
+
export declare function FilterChipsRow({ fields, onClearAll, className, 'data-testid': testId, }: FilterChipsRowProps): import("react").JSX.Element | null;
|
|
60
|
+
//# sourceMappingURL=filter-chips.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"filter-chips.d.ts","sourceRoot":"","sources":["../src/filter-chips.tsx"],"names":[],"mappings":"AASA;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACjC,MAAM,EAAE,MAAM,EAAE,GAAG,SAAS,EAC5B,OAAO,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,EAAE,GAAG,SAAS,EACvD,QAAQ,SAAI,GACb,MAAM,CAsBR;AAYD;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE;IACnC,cAAc,EAAE,MAAM,EAAE,CAAA;IACxB,OAAO,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAC/C,GAAG,MAAM,GAAG,SAAS,CAUrB;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,SAAS;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,EAC7D,OAAO,EAAE,CAAC,EAAE,EACZ,SAAS,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GACnC,CAAC,EAAE,CAEL;AAED,MAAM,WAAW,eAAe;IAC5B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE;QACJ,cAAc,EAAE,MAAM,EAAE,CAAA;QACxB,OAAO,EAAE;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,MAAM,CAAC;YAAC,KAAK,CAAC,EAAE,MAAM,CAAA;SAAE,EAAE,CAAA;QAC3D,SAAS,EAAE,MAAM,CAAA;QACjB,cAAc,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,IAAI,CAAA;KAChE,CAAA;CACJ;AAED,MAAM,WAAW,mBAAmB;IAChC,uDAAuD;IACvD,MAAM,EAAE,eAAe,EAAE,CAAA;IACzB,yDAAyD;IACzD,UAAU,EAAE,MAAM,IAAI,CAAA;IACtB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,aAAa,CAAC,EAAE,MAAM,CAAA;CACzB;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,EAC3B,MAAM,EACN,UAAU,EACV,SAAS,EACT,aAAa,EAAE,MAAM,GACxB,EAAE,mBAAmB,sCA0DrB"}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Shared filter presentation: the removable active-filter chip row + the pure
|
|
3
|
+
// helpers that summarize a filter's value and resolve its color. Used by BOTH
|
|
4
|
+
// DynamicKanban and DynamicTable so the two surfaces render identical chips
|
|
5
|
+
// (one component, no drift).
|
|
6
|
+
import { useTranslation } from 'react-i18next';
|
|
7
|
+
import { X } from 'lucide-react';
|
|
8
|
+
import { Badge, Button } from '@asteby/metacore-ui/primitives';
|
|
9
|
+
import { resolveColorCss } from '@asteby/metacore-ui/lib';
|
|
10
|
+
/**
|
|
11
|
+
* Human-readable summary of a field's selected filter values for a chip / row.
|
|
12
|
+
* Resolves option labels, unwraps the wire operators
|
|
13
|
+
* (`IN:`/`ILIKE:`/`RANGE:`/`GTE:`/`LTE:`/date `from_to`), and caps the list at
|
|
14
|
+
* `maxShown` values with a `+n` overflow. Pure — exported for unit tests.
|
|
15
|
+
*/
|
|
16
|
+
export function summarizeFilterValues(values, options, maxShown = 2) {
|
|
17
|
+
if (!values || values.length === 0)
|
|
18
|
+
return '';
|
|
19
|
+
const opts = options ?? [];
|
|
20
|
+
const labelFor = (v) => opts.find((o) => o.value === v)?.label ?? v;
|
|
21
|
+
const first = values[0];
|
|
22
|
+
if (values.length === 1) {
|
|
23
|
+
if (first.startsWith('ILIKE:'))
|
|
24
|
+
return `"${first.slice(6)}"`;
|
|
25
|
+
if (first.startsWith('IN:')) {
|
|
26
|
+
return summarizeList(first.slice(3).split(','), labelFor, maxShown);
|
|
27
|
+
}
|
|
28
|
+
if (first.startsWith('RANGE:')) {
|
|
29
|
+
const [min, max] = first.slice(6).split(',');
|
|
30
|
+
return `${min || '…'} – ${max || '…'}`;
|
|
31
|
+
}
|
|
32
|
+
if (/^\d{4}-\d{2}-\d{2}_/.test(first))
|
|
33
|
+
return first.replace('_', ' – ');
|
|
34
|
+
}
|
|
35
|
+
if (first.startsWith('GTE:') || first.startsWith('LTE:')) {
|
|
36
|
+
const min = values.find((v) => v.startsWith('GTE:'))?.slice(4) ?? '';
|
|
37
|
+
const max = values.find((v) => v.startsWith('LTE:'))?.slice(4) ?? '';
|
|
38
|
+
return `${min || '…'} – ${max || '…'}`;
|
|
39
|
+
}
|
|
40
|
+
return summarizeList(values, labelFor, maxShown);
|
|
41
|
+
}
|
|
42
|
+
function summarizeList(items, labelFor, maxShown) {
|
|
43
|
+
const labels = items.map(labelFor);
|
|
44
|
+
if (labels.length <= maxShown)
|
|
45
|
+
return labels.join(', ');
|
|
46
|
+
return `${labels.slice(0, maxShown).join(', ')} +${labels.length - maxShown}`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The color of a filter's first selected value (for the chip's dot) — e.g. a
|
|
50
|
+
* stage's palette color. Returns a resolved CSS color, or undefined for
|
|
51
|
+
* operator/range/free-text values that carry no option color.
|
|
52
|
+
*/
|
|
53
|
+
export function chipValueColor(config) {
|
|
54
|
+
const sel = config.selectedValues;
|
|
55
|
+
if (!sel || sel.length === 0)
|
|
56
|
+
return undefined;
|
|
57
|
+
const first = sel[0];
|
|
58
|
+
let value = first;
|
|
59
|
+
if (first.startsWith('IN:'))
|
|
60
|
+
value = first.slice(3).split(',')[0];
|
|
61
|
+
else if (/^(ILIKE|RANGE|GTE|LTE):/.test(first))
|
|
62
|
+
return undefined;
|
|
63
|
+
else if (/^\d{4}-\d{2}-\d{2}_/.test(first))
|
|
64
|
+
return undefined;
|
|
65
|
+
const opt = config.options.find((o) => o.value === value);
|
|
66
|
+
return opt?.color ? resolveColorCss(opt.color) : undefined;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Translates option labels through the app translator (manifest i18n keys →
|
|
70
|
+
* localized text). A raw value with no matching key falls through to itself via
|
|
71
|
+
* `defaultValue`. Pure — exported for unit tests.
|
|
72
|
+
*/
|
|
73
|
+
export function translateOptionLabels(options, translate) {
|
|
74
|
+
return options.map((o) => ({ ...o, label: translate(o.label) }));
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The removable active-filter chip row shown under a board/table toolbar. Each
|
|
78
|
+
* chip: an optional value-color dot, "Campo: valor(es)" (capped + `+n`), and an
|
|
79
|
+
* X that clears that field; a trailing "Limpiar todo". Renders nothing when no
|
|
80
|
+
* field is active.
|
|
81
|
+
*/
|
|
82
|
+
export function FilterChipsRow({ fields, onClearAll, className, 'data-testid': testId, }) {
|
|
83
|
+
const { t } = useTranslation();
|
|
84
|
+
if (fields.length === 0)
|
|
85
|
+
return null;
|
|
86
|
+
return (_jsxs("div", { className: `flex flex-wrap items-center gap-1.5${className ? ` ${className}` : ''}`, "data-testid": testId, children: [fields.map((field) => {
|
|
87
|
+
const summary = summarizeFilterValues(field.config.selectedValues, field.config.options);
|
|
88
|
+
const dot = chipValueColor(field.config);
|
|
89
|
+
return (_jsxs(Badge, { variant: "secondary", className: "h-6 gap-1.5 rounded-md pl-2 pr-1 text-xs font-normal", children: [dot && (_jsx("span", { className: "size-2 shrink-0 rounded-full", style: { backgroundColor: dot } })), _jsxs("span", { className: "font-medium", children: [field.label, ":"] }), _jsx("span", { className: "max-w-[180px] truncate text-muted-foreground", children: summary }), _jsx("button", { type: "button", onClick: () => field.config.onFilterChange(field.config.filterKey, []), className: "ml-0.5 rounded-sm p-0.5 transition-colors hover:bg-muted-foreground/20", "aria-label": t('filters.removeFilter', {
|
|
90
|
+
defaultValue: 'Quitar filtro',
|
|
91
|
+
}), children: _jsx(X, { className: "h-3 w-3" }) })] }, field.key));
|
|
92
|
+
}), _jsx(Button, { variant: "ghost", size: "sm", className: "h-6 gap-1 px-2 text-xs text-muted-foreground", onClick: onClearAll, children: t('filters.clearAll', { defaultValue: 'Limpiar todo' }) })] }));
|
|
93
|
+
}
|
|
@@ -1 +1 @@
|
|
|
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,
|
|
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,CAyTzB"}
|
|
@@ -36,7 +36,7 @@ export function useDynamicFilters(metadata, opts = {}) {
|
|
|
36
36
|
: model
|
|
37
37
|
? `/data/${model}/facets`
|
|
38
38
|
: null);
|
|
39
|
-
const getFacetLoader = useFacetLoaders(facetsBase);
|
|
39
|
+
const { getFacetLoader, prefetchFacets, facetOptions } = useFacetLoaders(facetsBase);
|
|
40
40
|
// Prefetch the option lists for relation/select filters (once per model). The
|
|
41
41
|
// combobox needs these before the user opens it; mirrors DynamicTable's
|
|
42
42
|
// metadata-init option prefetch, narrowed to the FILTER endpoints only (the
|
|
@@ -152,6 +152,9 @@ export function useDynamicFilters(metadata, opts = {}) {
|
|
|
152
152
|
if (loader) {
|
|
153
153
|
fType = 'facet';
|
|
154
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) ?? [];
|
|
155
158
|
}
|
|
156
159
|
}
|
|
157
160
|
if (fType === 'select' && options.length === 0 && !f.searchEndpoint)
|
|
@@ -217,6 +220,8 @@ export function useDynamicFilters(metadata, opts = {}) {
|
|
|
217
220
|
if (loader) {
|
|
218
221
|
filterType = 'facet';
|
|
219
222
|
loadOptions = loader;
|
|
223
|
+
// Prewarmed values (from prefetchFacets) → instant open.
|
|
224
|
+
options = facetOptions.get(c.key) ?? [];
|
|
220
225
|
}
|
|
221
226
|
}
|
|
222
227
|
map.set(c.key, {
|
|
@@ -238,7 +243,26 @@ export function useDynamicFilters(metadata, opts = {}) {
|
|
|
238
243
|
handleDynamicFilterChange,
|
|
239
244
|
facetsBase,
|
|
240
245
|
getFacetLoader,
|
|
246
|
+
facetOptions,
|
|
241
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]);
|
|
242
266
|
const searchableKeys = useMemo(() => (metadata ? getSearchableColumnKeys(metadata) : null), [metadata]);
|
|
243
267
|
// Serialize to `/data/:model` query params — identical operator encoding to
|
|
244
268
|
// DynamicTable.buildFilterParams (IN:/RANGE:/GTE:/LTE:/ILIKE:/plain).
|
|
@@ -1,4 +1,21 @@
|
|
|
1
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
|
+
}
|
|
2
19
|
/**
|
|
3
20
|
* Long-text / body columns aren't worth faceting (thousands of unique values,
|
|
4
21
|
* each a paragraph). Heuristic on the metadata, not a hardcoded name list:
|
|
@@ -11,12 +28,11 @@ export declare function isLongTextColumn(c: {
|
|
|
11
28
|
cellStyle?: string;
|
|
12
29
|
}): boolean;
|
|
13
30
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* when `facetsBase` changes (model switch).
|
|
19
|
-
* factory that yields `undefined` — the caller keeps the column as plain text.
|
|
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).
|
|
20
36
|
*/
|
|
21
|
-
export declare function useFacetLoaders(facetsBase: string | null):
|
|
37
|
+
export declare function useFacetLoaders(facetsBase: string | null): UseFacetLoadersResult;
|
|
22
38
|
//# sourceMappingURL=use-facet-loaders.d.ts.map
|
|
@@ -1 +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
|
|
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"}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// per-field option loader + result cache lives here to guarantee they facet
|
|
5
5
|
// identically (one board and its table sibling hit the same `/facets` endpoint
|
|
6
6
|
// with the same caching).
|
|
7
|
-
import { useCallback, useEffect, useRef } from 'react';
|
|
7
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
8
8
|
import { useApi } from './api-context';
|
|
9
9
|
/**
|
|
10
10
|
* Long-text / body columns aren't worth faceting (thousands of unique values,
|
|
@@ -21,22 +21,32 @@ export function isLongTextColumn(c) {
|
|
|
21
21
|
return /^(body|description|content|notes?|comment|message|summary)$/i.test(c.key);
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* when `facetsBase` changes (model switch).
|
|
29
|
-
* factory that yields `undefined` — the caller keeps the column as plain text.
|
|
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).
|
|
30
29
|
*/
|
|
31
30
|
export function useFacetLoaders(facetsBase) {
|
|
32
31
|
const api = useApi();
|
|
33
32
|
const loaderCache = useRef(new Map());
|
|
34
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
|
+
}, []);
|
|
35
43
|
useEffect(() => {
|
|
36
44
|
loaderCache.current.clear();
|
|
37
45
|
resultCache.current.clear();
|
|
46
|
+
prefetchSig.current = null;
|
|
47
|
+
setFacetOptions(new Map());
|
|
38
48
|
}, [facetsBase]);
|
|
39
|
-
|
|
49
|
+
const getFacetLoader = useCallback((field) => {
|
|
40
50
|
if (!facetsBase)
|
|
41
51
|
return undefined;
|
|
42
52
|
const existing = loaderCache.current.get(field);
|
|
@@ -64,4 +74,35 @@ export function useFacetLoaders(facetsBase) {
|
|
|
64
74
|
loaderCache.current.set(field, loader);
|
|
65
75
|
return loader;
|
|
66
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 };
|
|
67
108
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "23.1.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": {
|
|
@@ -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.9.0"
|
|
72
72
|
},
|
|
73
73
|
"scripts": {
|
|
74
74
|
"build": "tsc -p tsconfig.json",
|
|
@@ -39,6 +39,9 @@ import {
|
|
|
39
39
|
applyOptimisticMove,
|
|
40
40
|
selectCardColumns,
|
|
41
41
|
cardMatchesLaneQuery,
|
|
42
|
+
cardMatchesLaneFunnel,
|
|
43
|
+
laneFunnelCount,
|
|
44
|
+
translateOptionLabels,
|
|
42
45
|
summarizeFilterValues,
|
|
43
46
|
UNASSIGNED_LANE,
|
|
44
47
|
DynamicKanban,
|
|
@@ -232,6 +235,92 @@ describe('cardMatchesLaneQuery', () => {
|
|
|
232
235
|
})
|
|
233
236
|
})
|
|
234
237
|
|
|
238
|
+
describe('cardMatchesLaneFunnel', () => {
|
|
239
|
+
// card 4: title "Refactor api", assignee "ana", priority "mid", stage "in_progress"
|
|
240
|
+
const card = CARDS[3]
|
|
241
|
+
|
|
242
|
+
it('matches picked select/facet values by equality (IN), not substring', () => {
|
|
243
|
+
// exact value matches
|
|
244
|
+
expect(
|
|
245
|
+
cardMatchesLaneFunnel(card, { field: 'assignee', values: ['ana'] }),
|
|
246
|
+
).toBe(true)
|
|
247
|
+
// a substring of the value must NOT match under equality
|
|
248
|
+
expect(
|
|
249
|
+
cardMatchesLaneFunnel(card, { field: 'assignee', values: ['an'] }),
|
|
250
|
+
).toBe(false)
|
|
251
|
+
// IN semantics: any of the picked values
|
|
252
|
+
expect(
|
|
253
|
+
cardMatchesLaneFunnel(card, {
|
|
254
|
+
field: 'assignee',
|
|
255
|
+
values: ['dani', 'ana'],
|
|
256
|
+
}),
|
|
257
|
+
).toBe(true)
|
|
258
|
+
expect(
|
|
259
|
+
cardMatchesLaneFunnel(card, {
|
|
260
|
+
field: 'assignee',
|
|
261
|
+
values: ['dani', 'eva'],
|
|
262
|
+
}),
|
|
263
|
+
).toBe(false)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('matches free text by case-insensitive substring', () => {
|
|
267
|
+
expect(cardMatchesLaneFunnel(card, { field: 'title', text: 'refac' })).toBe(
|
|
268
|
+
true,
|
|
269
|
+
)
|
|
270
|
+
expect(cardMatchesLaneFunnel(card, { field: 'title', text: 'REFAC' })).toBe(
|
|
271
|
+
true,
|
|
272
|
+
)
|
|
273
|
+
expect(cardMatchesLaneFunnel(card, { field: 'title', text: 'zzz' })).toBe(
|
|
274
|
+
false,
|
|
275
|
+
)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('passes when there is no field or no criteria', () => {
|
|
279
|
+
expect(cardMatchesLaneFunnel(card, undefined)).toBe(true)
|
|
280
|
+
expect(cardMatchesLaneFunnel(card, { field: 'assignee' })).toBe(true)
|
|
281
|
+
expect(
|
|
282
|
+
cardMatchesLaneFunnel(card, { field: 'assignee', values: [] }),
|
|
283
|
+
).toBe(true)
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
describe('laneFunnelCount', () => {
|
|
288
|
+
it('counts picked values, else 1 for free text, else 0', () => {
|
|
289
|
+
expect(laneFunnelCount(undefined)).toBe(0)
|
|
290
|
+
expect(laneFunnelCount({})).toBe(0)
|
|
291
|
+
expect(laneFunnelCount({ values: ['a', 'b', 'c'] })).toBe(3)
|
|
292
|
+
expect(laneFunnelCount({ text: 'foo' })).toBe(1)
|
|
293
|
+
expect(laneFunnelCount({ text: ' ' })).toBe(0)
|
|
294
|
+
// picked values win over stray text
|
|
295
|
+
expect(laneFunnelCount({ values: ['a'], text: 'x' })).toBe(1)
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
describe('translateOptionLabels', () => {
|
|
300
|
+
it('runs option labels through the translator (stage i18n keys → localized)', () => {
|
|
301
|
+
const opts = [
|
|
302
|
+
{ value: 'backlog', label: 'issue.stage.backlog', color: 'slate' },
|
|
303
|
+
{ value: 'done', label: 'issue.stage.done', color: 'green' },
|
|
304
|
+
]
|
|
305
|
+
const dict: Record<string, string> = {
|
|
306
|
+
'issue.stage.backlog': 'Pendiente',
|
|
307
|
+
'issue.stage.done': 'Hecho',
|
|
308
|
+
}
|
|
309
|
+
const out = translateOptionLabels(opts, (k) => dict[k] ?? k)
|
|
310
|
+
expect(out.map((o) => o.label)).toEqual(['Pendiente', 'Hecho'])
|
|
311
|
+
// value + color preserved
|
|
312
|
+
expect(out[0]).toMatchObject({ value: 'backlog', color: 'slate' })
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('leaves raw values (no matching key) untouched', () => {
|
|
316
|
+
const out = translateOptionLabels(
|
|
317
|
+
[{ value: 'acme/repo', label: 'acme/repo' }],
|
|
318
|
+
(k) => k,
|
|
319
|
+
)
|
|
320
|
+
expect(out[0].label).toBe('acme/repo')
|
|
321
|
+
})
|
|
322
|
+
})
|
|
323
|
+
|
|
235
324
|
describe('summarizeFilterValues', () => {
|
|
236
325
|
const opts = [
|
|
237
326
|
{ value: 'backlog', label: 'Backlog' },
|
|
@@ -428,3 +517,55 @@ describe('DynamicKanban lane search', () => {
|
|
|
428
517
|
expect(screen.getByText('Fix login bug')).toBeTruthy()
|
|
429
518
|
})
|
|
430
519
|
})
|
|
520
|
+
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
// 6. Lane funnel — for a field with options (the Stage select), the value step
|
|
523
|
+
// renders the pro multi-select combobox, NOT a raw text input.
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
|
|
526
|
+
describe('DynamicKanban lane funnel', () => {
|
|
527
|
+
it('renders the value combobox for a field that has options', async () => {
|
|
528
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
529
|
+
render(
|
|
530
|
+
<ApiProvider client={fakeApi()}>
|
|
531
|
+
<DynamicKanban model="issue" />
|
|
532
|
+
</ApiProvider>,
|
|
533
|
+
)
|
|
534
|
+
await screen.findByText('Fix login bug')
|
|
535
|
+
// open the first lane's funnel (the only filterable field is Stage → select)
|
|
536
|
+
const funnelButtons = screen.getAllByLabelText('Filtrar columna')
|
|
537
|
+
fireEvent.click(funnelButtons[0])
|
|
538
|
+
// the value step is the searchable combobox, not the old raw text box
|
|
539
|
+
expect(
|
|
540
|
+
await screen.findByPlaceholderText('Buscar valores...'),
|
|
541
|
+
).toBeTruthy()
|
|
542
|
+
expect(screen.queryByPlaceholderText('Contiene...')).toBeNull()
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
it('shows a count badge on the funnel with the number of picked values', async () => {
|
|
546
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
547
|
+
render(
|
|
548
|
+
<ApiProvider client={fakeApi()}>
|
|
549
|
+
<DynamicKanban model="issue" />
|
|
550
|
+
</ApiProvider>,
|
|
551
|
+
)
|
|
552
|
+
await screen.findByText('Fix login bug')
|
|
553
|
+
fireEvent.click(screen.getAllByLabelText('Filtrar columna')[0])
|
|
554
|
+
await screen.findByPlaceholderText('Buscar valores...')
|
|
555
|
+
|
|
556
|
+
// pick two stage values from the combobox
|
|
557
|
+
const options = screen.getAllByRole('option')
|
|
558
|
+
expect(options.length).toBeGreaterThanOrEqual(2)
|
|
559
|
+
fireEvent.click(options[0])
|
|
560
|
+
fireEvent.click(options[1])
|
|
561
|
+
// apply
|
|
562
|
+
fireEvent.click(screen.getByText(/Aplicar/))
|
|
563
|
+
|
|
564
|
+
// the first lane's funnel button now carries a "2" count badge
|
|
565
|
+
await waitFor(() =>
|
|
566
|
+
expect(
|
|
567
|
+
screen.getAllByLabelText('Filtrar columna')[0].textContent,
|
|
568
|
+
).toContain('2'),
|
|
569
|
+
)
|
|
570
|
+
})
|
|
571
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// DynamicTable filter parity with the kanban:
|
|
4
|
+
// - Facet PREFETCH: on metadata resolve, the table warms every facet field in
|
|
5
|
+
// one burst (`/data/<model>/facets?field=<key>`), so a text-column header
|
|
6
|
+
// filter opens instantly.
|
|
7
|
+
// - Stage-select: the group_by/stage column with no options of its own becomes
|
|
8
|
+
// a select (NOT a facet) — so it must NOT trigger a facets prewarm.
|
|
9
|
+
// - Long-text columns stay plain text (no facet, no prewarm).
|
|
10
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
11
|
+
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
|
12
|
+
|
|
13
|
+
vi.mock('@tanstack/react-router', () => ({
|
|
14
|
+
useNavigate: () => () => {},
|
|
15
|
+
}))
|
|
16
|
+
const I18N = { t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k, i18n: { language: 'es' } }
|
|
17
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => I18N }))
|
|
18
|
+
|
|
19
|
+
import { DynamicTable } from '../dynamic-table'
|
|
20
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
21
|
+
import { useMetadataCache } from '../metadata-cache'
|
|
22
|
+
import type { TableMetadata } from '../types'
|
|
23
|
+
|
|
24
|
+
afterEach(cleanup)
|
|
25
|
+
|
|
26
|
+
const STAGES = [
|
|
27
|
+
{ key: 'backlog', label: 'issue.stage.backlog', color: 'slate', order: 0 },
|
|
28
|
+
{ key: 'done', label: 'issue.stage.done', color: 'green', order: 1 },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
function meta(): TableMetadata {
|
|
32
|
+
return {
|
|
33
|
+
title: 'Issues',
|
|
34
|
+
endpoint: '/data/issue',
|
|
35
|
+
group_by: 'stage',
|
|
36
|
+
stages: STAGES,
|
|
37
|
+
columns: [
|
|
38
|
+
{ key: 'title', label: 'Title', type: 'text', sortable: true, filterable: true, searchable: true },
|
|
39
|
+
{ key: 'body', label: 'Body', type: 'text', sortable: false, filterable: true, cellStyle: 'truncate-text' },
|
|
40
|
+
{ key: 'stage', label: 'Stage', type: 'status', sortable: false, filterable: true },
|
|
41
|
+
],
|
|
42
|
+
actions: [],
|
|
43
|
+
perPageOptions: [50],
|
|
44
|
+
defaultPerPage: 50,
|
|
45
|
+
searchPlaceholder: 'Buscar...',
|
|
46
|
+
enableCRUDActions: true,
|
|
47
|
+
hasActions: false,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fakeApi(): ApiClient {
|
|
52
|
+
const ok = (data: unknown) => ({ data: { success: true, data, meta: { total: 0 } } })
|
|
53
|
+
return {
|
|
54
|
+
get: vi.fn(async (url: string, cfg?: any) => {
|
|
55
|
+
if (url.startsWith('/metadata/table/')) return ok(meta())
|
|
56
|
+
if (url.endsWith('/facets')) {
|
|
57
|
+
const f = cfg?.params?.field
|
|
58
|
+
return ok([
|
|
59
|
+
{ value: `${f}-a`, label: `${f} A`, count: 2 },
|
|
60
|
+
{ value: `${f}-b`, label: `${f} B`, count: 1 },
|
|
61
|
+
])
|
|
62
|
+
}
|
|
63
|
+
return ok([])
|
|
64
|
+
}),
|
|
65
|
+
post: vi.fn(async () => ok(null)),
|
|
66
|
+
put: vi.fn(async () => ok(null)),
|
|
67
|
+
delete: vi.fn(async () => ok(null)),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function facetFields(api: ApiClient): string[] {
|
|
72
|
+
return (api.get as any).mock.calls
|
|
73
|
+
.filter((c: any[]) => String(c[0]).endsWith('/facets'))
|
|
74
|
+
.map((c: any[]) => c[1]?.params?.field)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('DynamicTable facet prefetch + stage-select', () => {
|
|
78
|
+
it('prewarms the plain text column but NOT the stage (select) or long-text columns', async () => {
|
|
79
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
80
|
+
const api = fakeApi()
|
|
81
|
+
render(
|
|
82
|
+
<ApiProvider client={api}>
|
|
83
|
+
<DynamicTable model="issue" />
|
|
84
|
+
</ApiProvider>,
|
|
85
|
+
)
|
|
86
|
+
// 'title' is a facet → it gets prewarmed against the model-derived endpoint
|
|
87
|
+
await waitFor(() =>
|
|
88
|
+
expect(
|
|
89
|
+
(api.get as any).mock.calls.some(
|
|
90
|
+
(c: any[]) =>
|
|
91
|
+
String(c[0]) === '/data/issue/facets' &&
|
|
92
|
+
c[1]?.params?.field === 'title',
|
|
93
|
+
),
|
|
94
|
+
).toBe(true),
|
|
95
|
+
)
|
|
96
|
+
const fields = facetFields(api)
|
|
97
|
+
// stage → select (from the pipeline stages), never faceted
|
|
98
|
+
expect(fields).not.toContain('stage')
|
|
99
|
+
// body → long-text, stays plain text, never faceted
|
|
100
|
+
expect(fields).not.toContain('body')
|
|
101
|
+
})
|
|
102
|
+
})
|