@asteby/metacore-runtime-react 21.0.0 → 22.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,6 +24,7 @@ import { Progress } from './dialogs/_primitives';
24
24
  import { useMetadataCache } from './metadata-cache';
25
25
  import { useApi, useCurrentBranch } from './api-context';
26
26
  import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns';
27
+ import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders';
27
28
  import { OptionsContext } from './options-context';
28
29
  import { getSearchableColumnKeys } from './column-visibility';
29
30
  import { useCan, usePermissionsActive, gateTableMetadata } from './permissions-context';
@@ -456,16 +457,28 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
456
457
  setDynamicFilters((prev) => ({ ...prev, [filterKey]: values }));
457
458
  setPagination((prev) => ({ ...prev, pageIndex: 0 }));
458
459
  }, []);
460
+ // Same facet loader machinery the board uses, so a text column filters
461
+ // identically in the table header and the kanban Sheet (the host's
462
+ // getDynamicColumns forwards `loadOptions` into the column meta). Facets base
463
+ // derived off the list endpoint exactly like the aggregate endpoint below.
464
+ const facetsBase = endpoint ? `${endpoint}/facets` : model ? `/data/${model}/facets` : null;
465
+ const getFacetLoader = useFacetLoaders(facetsBase);
459
466
  const columnFilterConfigs = useMemo(() => {
460
467
  const map = new Map();
461
468
  if (!metadata)
462
469
  return map;
470
+ const stageOptions = (metadata.stages ?? []).map((s) => ({
471
+ label: s.label,
472
+ value: s.key,
473
+ color: s.color,
474
+ }));
475
+ const groupBy = metadata.group_by;
463
476
  // Explicit `metadata.filters` wins. When the backend does not emit
464
477
  // them, derive a filter chip from every column flagged
465
478
  // `filterable: true` — keeps the kernel API minimal (one flag on the
466
479
  // column) while still rendering the FilterableColumnHeader.
467
480
  for (const f of metadata.filters ?? []) {
468
- const fType = f.type;
481
+ let fType = f.type;
469
482
  let options = [];
470
483
  if (f.options && f.options.length > 0) {
471
484
  options = f.options.map(o => ({ label: o.label, value: String(o.value), icon: o.icon, color: o.color }));
@@ -473,6 +486,22 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
473
486
  if (f.searchEndpoint && filterOptionsMap.has(f.searchEndpoint)) {
474
487
  options = filterOptionsMap.get(f.searchEndpoint) || [];
475
488
  }
489
+ // (A) A stage column with no options of its own inherits the
490
+ // pipeline stages (with their colors) as a real select.
491
+ if (options.length === 0 && !f.searchEndpoint && (f.column || f.key) === groupBy && stageOptions.length > 0) {
492
+ fType = 'select';
493
+ options = stageOptions;
494
+ }
495
+ // (B) A plain text filter becomes a facet value-picker when a facets
496
+ // endpoint is available (degrades to "Contiene..." if it yields nothing).
497
+ let loadOptions;
498
+ if (fType === 'text' && facetsBase) {
499
+ const loader = getFacetLoader(f.column || f.key);
500
+ if (loader) {
501
+ fType = 'facet';
502
+ loadOptions = loader;
503
+ }
504
+ }
476
505
  if (fType === 'select' && options.length === 0 && !f.searchEndpoint)
477
506
  continue;
478
507
  map.set(f.key, {
@@ -483,6 +512,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
483
512
  onFilterChange: handleDynamicFilterChange,
484
513
  loading: f.searchEndpoint ? !filterOptionsMap.has(f.searchEndpoint) : false,
485
514
  searchEndpoint: f.searchEndpoint,
515
+ loadOptions,
486
516
  });
487
517
  }
488
518
  for (const c of metadata.columns ?? []) {
@@ -500,8 +530,12 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
500
530
  // - number / number_range / numeric → number range
501
531
  // - date → date range picker (start/end calendar)
502
532
  // - everything else (text, email, phone, tags…) → text contains
533
+ // (A) Stage column with no options of its own → colored select.
534
+ const isStageColumn = c.key === groupBy && !hasStaticOptions && !hasEndpoint && !c.filterType && stageOptions.length > 0;
503
535
  let filterType;
504
- if (c.filterType)
536
+ if (isStageColumn)
537
+ filterType = 'select';
538
+ else if (c.filterType)
505
539
  filterType = c.filterType;
506
540
  else if (isRelation && hasEndpoint)
507
541
  filterType = 'dynamic_select';
@@ -515,7 +549,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
515
549
  filterType = 'date_range';
516
550
  else
517
551
  filterType = 'text';
518
- const options = hasStaticOptions
552
+ let options = hasStaticOptions
519
553
  ? c.options.map(o => ({
520
554
  label: o.label,
521
555
  value: String(o.value),
@@ -525,6 +559,18 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
525
559
  : hasEndpoint && filterOptionsMap.has(c.searchEndpoint)
526
560
  ? filterOptionsMap.get(c.searchEndpoint) || []
527
561
  : [];
562
+ if (isStageColumn)
563
+ options = stageOptions;
564
+ // (B) Upgrade a plain text filter to a facet value-picker unless it's
565
+ // a long-text/body column (too many unique values to enumerate).
566
+ let loadOptions;
567
+ if (filterType === 'text' && facetsBase && !isLongTextColumn(c)) {
568
+ const loader = getFacetLoader(c.key);
569
+ if (loader) {
570
+ filterType = 'facet';
571
+ loadOptions = loader;
572
+ }
573
+ }
528
574
  map.set(c.key, {
529
575
  filterType,
530
576
  filterKey: c.key,
@@ -533,10 +579,11 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
533
579
  onFilterChange: handleDynamicFilterChange,
534
580
  loading: hasEndpoint && !filterOptionsMap.has(c.searchEndpoint),
535
581
  searchEndpoint: c.searchEndpoint,
582
+ loadOptions,
536
583
  });
537
584
  }
538
585
  return map;
539
- }, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange]);
586
+ }, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange, facetsBase, getFacetLoader]);
540
587
  const columns = useMemo(() => {
541
588
  if (!viewMetadata)
542
589
  return [];
@@ -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":"AAgBA,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;CACrC;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,CAqNzB"}
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
- const fType = f.type;
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 (c.filterType)
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
- const options = hasStaticOptions
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
- }, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange]);
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": "21.0.0",
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.7.0"
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.7.0"
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
  // ---------------------------------------------------------------------------
@@ -285,18 +335,23 @@ describe('optimistic drag contract', () => {
285
335
  // ---------------------------------------------------------------------------
286
336
 
287
337
  describe('DynamicKanban filter bar', () => {
288
- it('renders a search box and a chip for each filterable field', async () => {
338
+ it('renders a search box and groups filters behind a Filtros sheet', async () => {
289
339
  useMetadataCache.getState().setMetadata('issue', meta())
290
340
  render(
291
341
  <ApiProvider client={fakeApi()}>
292
342
  <DynamicKanban model="issue" />
293
343
  </ApiProvider>,
294
344
  )
295
- // search box
345
+ // search box stays inline
296
346
  expect(await screen.findByPlaceholderText('Buscar...')).toBeTruthy()
297
- // the only `filterable: true` column is `stage` a "Stage" filter chip.
298
- // (Lane headers use the STAGE labels Backlog/In Progress/… never "Stage".)
299
- expect(screen.getByText('Stage')).toBeTruthy()
347
+ // filters are grouped behind a "Filtros" button (no longer spilling as
348
+ // inline chips), so "Stage" is NOT in the DOM until the sheet opens.
349
+ const filtersBtn = screen.getByText('Filtros')
350
+ expect(filtersBtn).toBeTruthy()
351
+ expect(screen.queryByText('Stage')).toBeNull()
352
+ // opening the sheet reveals the "Stage" filter (the only filterable column)
353
+ fireEvent.click(filtersBtn)
354
+ expect(await screen.findByText('Stage')).toBeTruthy()
300
355
  })
301
356
 
302
357
  it('typing in the search box refetches the board with the search param', async () => {
@@ -327,3 +382,49 @@ describe('DynamicKanban filter bar', () => {
327
382
  )
328
383
  })
329
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
+ })