@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.
@@ -1,12 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors, useDraggable, useDroppable, } from '@dnd-kit/core';
5
- import { ListFilter, MoreHorizontal, Search, X } from 'lucide-react';
5
+ import { Calendar, CircleDot, Hash, ListFilter, MoreHorizontal, Search, Tag, ToggleLeft, Type, X, } from 'lucide-react';
6
6
  import { toast } from 'sonner';
7
7
  import { Badge, Button, Card, CardContent, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Input, Popover, PopoverContent, PopoverTrigger, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, Skeleton, } from '@asteby/metacore-ui/primitives';
8
- import { ColumnFilterControl } from '@asteby/metacore-ui/data-table';
9
- import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib';
8
+ import { ColumnFilterControl, FilterValueCombobox } from '@asteby/metacore-ui/data-table';
9
+ import { generateBadgeStyles, optionColor, resolveColorCss } from '@asteby/metacore-ui/lib';
10
10
  import { useApi } from './api-context';
11
11
  import { useDynamicFilters } from './use-dynamic-filters';
12
12
  import { useMetadataCache } from './metadata-cache';
@@ -129,6 +129,103 @@ export function selectCardColumns(metadata, maxFields = 3) {
129
129
  .slice(0, maxFields);
130
130
  return { title, fields };
131
131
  }
132
+ /**
133
+ * Human-readable summary of a field's selected filter values for the removable
134
+ * chip row. Resolves option labels, unwraps the wire operators
135
+ * (`IN:`/`ILIKE:`/`RANGE:`/`GTE:`/`LTE:`/date `from_to`), and caps the list at
136
+ * `maxShown` values with a `+n` overflow. Pure — exported for unit tests.
137
+ */
138
+ export function summarizeFilterValues(values, options, maxShown = 2) {
139
+ if (!values || values.length === 0)
140
+ return '';
141
+ const opts = options ?? [];
142
+ const labelFor = (v) => opts.find((o) => o.value === v)?.label ?? v;
143
+ const first = values[0];
144
+ if (values.length === 1) {
145
+ if (first.startsWith('ILIKE:'))
146
+ return `"${first.slice(6)}"`;
147
+ if (first.startsWith('IN:')) {
148
+ return summarizeList(first.slice(3).split(','), labelFor, maxShown);
149
+ }
150
+ if (first.startsWith('RANGE:')) {
151
+ const [min, max] = first.slice(6).split(',');
152
+ return `${min || '…'} – ${max || '…'}`;
153
+ }
154
+ if (/^\d{4}-\d{2}-\d{2}_/.test(first))
155
+ return first.replace('_', ' – ');
156
+ }
157
+ if (first.startsWith('GTE:') || first.startsWith('LTE:')) {
158
+ const min = values.find((v) => v.startsWith('GTE:'))?.slice(4) ?? '';
159
+ const max = values.find((v) => v.startsWith('LTE:'))?.slice(4) ?? '';
160
+ return `${min || '…'} – ${max || '…'}`;
161
+ }
162
+ return summarizeList(values, labelFor, maxShown);
163
+ }
164
+ function summarizeList(items, labelFor, maxShown) {
165
+ const labels = items.map(labelFor);
166
+ if (labels.length <= maxShown)
167
+ return labels.join(', ');
168
+ return `${labels.slice(0, maxShown).join(', ')} +${labels.length - maxShown}`;
169
+ }
170
+ /**
171
+ * The color of a filter's first selected value (for the chip's dot) — e.g. a
172
+ * stage's palette color. Returns a resolved CSS color, or undefined for
173
+ * operator/range/free-text values that carry no option color.
174
+ */
175
+ function chipValueColor(config) {
176
+ const sel = config.selectedValues;
177
+ if (!sel || sel.length === 0)
178
+ return undefined;
179
+ const first = sel[0];
180
+ let value = first;
181
+ if (first.startsWith('IN:'))
182
+ value = first.slice(3).split(',')[0];
183
+ else if (/^(ILIKE|RANGE|GTE|LTE):/.test(first))
184
+ return undefined;
185
+ else if (/^\d{4}-\d{2}-\d{2}_/.test(first))
186
+ return undefined;
187
+ const opt = config.options.find((o) => o.value === value);
188
+ return opt?.color ? resolveColorCss(opt.color) : undefined;
189
+ }
190
+ /**
191
+ * Whether a card passes a lane funnel. Picked select/facet `values` match by
192
+ * equality (IN — the card's field value must be one of them); a free-text
193
+ * `text` matches by case-insensitive substring. No field / no criteria → passes.
194
+ * Pure — exported for unit tests.
195
+ */
196
+ export function cardMatchesLaneFunnel(card, filter) {
197
+ if (!filter?.field)
198
+ return true;
199
+ const raw = String(card?.[filter.field] ?? '');
200
+ if (filter.values && filter.values.length > 0) {
201
+ return filter.values.includes(raw);
202
+ }
203
+ if (filter.text?.trim()) {
204
+ return raw.toLowerCase().includes(filter.text.trim().toLowerCase());
205
+ }
206
+ return true;
207
+ }
208
+ /**
209
+ * Translates option labels through the app translator (manifest i18n keys →
210
+ * localized text). A raw value with no matching key falls through to itself via
211
+ * `defaultValue`. Pure — exported for unit tests.
212
+ */
213
+ export function translateOptionLabels(options, translate) {
214
+ return options.map((o) => ({ ...o, label: translate(o.label) }));
215
+ }
216
+ /**
217
+ * Whether a card matches a free-text lane search: a case-insensitive substring
218
+ * over the card's title + every visible field value (`String(v)`). Empty query
219
+ * matches everything. Pure — exported for unit tests.
220
+ */
221
+ export function cardMatchesLaneQuery(card, cols, query) {
222
+ const q = query.trim().toLowerCase();
223
+ if (!q)
224
+ return true;
225
+ return cols.some((c) => String(card?.[c.key] ?? '')
226
+ .toLowerCase()
227
+ .includes(q));
228
+ }
132
229
  // ---------------------------------------------------------------------------
133
230
  // Theme hook (mirrors the private one in dynamic-columns / activity-renderer)
134
231
  // ---------------------------------------------------------------------------
@@ -199,7 +296,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
199
296
  // Shared metadata-driven filter engine — the SAME configs, option prefetch
200
297
  // and `f_<key>` serialization DynamicTable uses, so the board filters
201
298
  // identically to its table sibling.
202
- const { globalFilter, setGlobalFilter, columnFilterConfigs, filterParams, activeFilterCount, clearAll, } = useDynamicFilters(metadata, { defaultFilters });
299
+ const { dynamicFilters, globalFilter, setGlobalFilter, columnFilterConfigs, filterParams, activeFilterCount, handleDynamicFilterChange, clearAll, } = useDynamicFilters(metadata, { defaultFilters, model, endpoint });
203
300
  // ---- records fetch (same path as DynamicTable, single large page) ----
204
301
  const fetchData = useCallback(async () => {
205
302
  if (!metadata)
@@ -237,26 +334,64 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
237
334
  if (!metadata)
238
335
  return [];
239
336
  const out = [];
337
+ // Option labels come from the manifest as i18n keys (e.g.
338
+ // "integration_github.stage.backlog"). ColumnFilterControl lives in the
339
+ // ui package (no i18n), so translate labels HERE — on the static options
340
+ // and on whatever the facet loader resolves — before they ever reach a
341
+ // control, chip or value summary. A raw value (a repo name) has no key,
342
+ // so t() returns it verbatim via defaultValue.
343
+ const tr = (label) => t(label, { defaultValue: label });
240
344
  for (const [key, config] of columnFilterConfigs) {
241
345
  const f = metadata.filters?.find((x) => x.key === key);
242
346
  const c = metadata.columns.find((x) => x.key === key);
243
347
  const rawLabel = f?.label || c?.label || key;
244
- out.push({ key, label: t(rawLabel, { defaultValue: rawLabel }), config });
348
+ const translatedConfig = {
349
+ ...config,
350
+ options: translateOptionLabels(config.options, tr),
351
+ loadOptions: config.loadOptions
352
+ ? (q) => config.loadOptions(q).then((opts) => translateOptionLabels(opts, tr))
353
+ : undefined,
354
+ };
355
+ out.push({
356
+ key,
357
+ label: tr(rawLabel),
358
+ config: translatedConfig,
359
+ });
245
360
  }
246
361
  return out;
247
362
  }, [metadata, columnFilterConfigs, t]);
363
+ // Split filters into active (with a selection) and the rest — the Sheet
364
+ // groups the active ones on top, the rest alphabetically. Also drives the
365
+ // removable chip row below the toolbar.
366
+ const { activeFields, inactiveFields } = useMemo(() => {
367
+ const active = filterFields.filter((f) => (f.config.selectedValues?.length ?? 0) > 0);
368
+ const inactive = filterFields
369
+ .filter((f) => (f.config.selectedValues?.length ?? 0) === 0)
370
+ .sort((a, b) => a.label.localeCompare(b.label));
371
+ return { activeFields: active, inactiveFields: inactive };
372
+ }, [filterFields]);
248
373
  // Sheet (grouped global filters) open state + per-lane client-side filters.
249
374
  // A lane filter narrows ONLY that stage's already-fetched cards by a field
250
375
  // value — instant, no refetch — so a user can drill into one column without
251
376
  // touching the rest of the board (the global filters, by contrast, refetch
252
377
  // the whole board server-side).
253
378
  const [filtersOpen, setFiltersOpen] = useState(false);
379
+ // Per-lane client-side narrowing. Two independent, AND-combined dimensions:
380
+ // - `field`/`value`: the funnel — a field-scoped substring match.
381
+ // - `query`: the lane search — a substring over the card title + every
382
+ // visible field value.
383
+ // A lane with neither is dropped from the map (so it reads as "unfiltered").
254
384
  const [laneFilters, setLaneFilters] = useState({});
255
- const setLaneFilter = useCallback((stageKey, filter) => {
385
+ const updateLaneFilter = useCallback((stageKey, patch) => {
256
386
  setLaneFilters((prev) => {
387
+ const merged = { ...prev[stageKey], ...patch };
257
388
  const next = { ...prev };
258
- if (filter)
259
- next[stageKey] = filter;
389
+ const hasFunnel = !!(merged.field &&
390
+ ((merged.values && merged.values.length > 0) ||
391
+ merged.text?.trim()));
392
+ const hasQuery = !!merged.query?.trim();
393
+ if (hasFunnel || hasQuery)
394
+ next[stageKey] = merged;
260
395
  else
261
396
  delete next[stageKey];
262
397
  return next;
@@ -267,6 +402,8 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
267
402
  const transitions = metadata?.transitions;
268
403
  const grouped = useMemo(() => groupByStage(records, groupByKey, stages), [records, groupByKey, stages]);
269
404
  const { title: titleCol, fields: fieldCols } = useMemo(() => (metadata ? selectCardColumns(metadata) : { title: null, fields: [] }), [metadata]);
405
+ // Columns the lane search scans: the card title + its visible field cells.
406
+ const searchCols = useMemo(() => [titleCol, ...fieldCols].filter(Boolean), [titleCol, fieldCols]);
270
407
  // Row-placement actions resolved EXACTLY like DynamicTable's action column:
271
408
  // capability-gated (when a <PermissionsProvider> is mounted) and with the
272
409
  // implicit View/Edit/Delete trio materialized for CRUD models. An action the
@@ -362,37 +499,122 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
362
499
  order: Number.MAX_SAFE_INTEGER,
363
500
  });
364
501
  }
365
- return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", "data-testid": "kanban-filters", children: [_jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { value: globalFilter, onChange: (e) => setGlobalFilter(e.target.value), placeholder: t('kanban.searchPlaceholder', { defaultValue: 'Buscar...' }), className: "h-8 w-52 pl-8 text-sm" })] }), filterFields.length > 0 && (_jsxs(Sheet, { open: filtersOpen, onOpenChange: setFiltersOpen, children: [_jsx(SheetTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", size: "sm", className: "h-8 gap-1.5", children: [_jsx(ListFilter, { className: "h-3.5 w-3.5" }), t('kanban.filters', { defaultValue: 'Filtros' }), activeFilterCount > 0 && (_jsx(Badge, { variant: "secondary", className: "ml-0.5 h-4 min-w-4 justify-center rounded-full px-1 text-[10px] tabular-nums", children: activeFilterCount }))] }) }), _jsxs(SheetContent, { side: "right", className: "flex w-80 flex-col gap-4 sm:max-w-sm", children: [_jsx(SheetHeader, { className: "space-y-0", children: _jsx(SheetTitle, { children: t('kanban.filters', { defaultValue: 'Filtros' }) }) }), _jsx("div", { className: "flex flex-1 flex-col gap-2 overflow-y-auto pr-1", children: filterFields.map((field) => (_jsx(ColumnFilterControl, { showLabel: true, label: field.label, filterKey: field.config.filterKey, filterType: field.config.filterType, filterOptions: field.config.options, filterLoading: field.config.loading, filterSearchEndpoint: field.config.searchEndpoint, selectedValues: field.config.selectedValues, onFilterChange: field.config.onFilterChange }, field.key))) }), activeFilterCount > 0 && (_jsxs(Button, { variant: "ghost", size: "sm", className: "gap-1 text-xs text-muted-foreground", onClick: clearAll, children: [_jsx(X, { className: "h-3.5 w-3.5" }), t('kanban.clearFilters', { defaultValue: 'Limpiar' }), ` (${activeFilterCount})`] }))] })] }))] }), _jsxs(DndContext, { sensors: sensors, onDragStart: onDragStart, onDragEnd: onDragEnd, children: [_jsx("div", { className: "flex min-w-0 gap-4 overflow-x-auto p-1", "data-testid": "kanban-board", children: lanes.map((stage) => {
502
+ return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", "data-testid": "kanban-filters", children: [_jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { value: globalFilter, onChange: (e) => setGlobalFilter(e.target.value), placeholder: t('kanban.searchPlaceholder', { defaultValue: 'Buscar...' }), className: "h-8 w-52 pl-8 text-sm" })] }), filterFields.length > 0 && (_jsxs(Sheet, { open: filtersOpen, onOpenChange: setFiltersOpen, children: [_jsx(SheetTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", size: "sm", className: "h-8 gap-1.5", children: [_jsx(ListFilter, { className: "h-3.5 w-3.5" }), t('kanban.filters', { defaultValue: 'Filtros' }), activeFilterCount > 0 && (_jsx(Badge, { variant: "secondary", className: "ml-0.5 h-4 min-w-4 justify-center rounded-full px-1 text-[10px] tabular-nums", children: activeFilterCount }))] }) }), _jsxs(SheetContent, { side: "right", className: "flex w-80 flex-col gap-0 p-0 sm:max-w-sm", children: [_jsx(SheetHeader, { className: "space-y-0 border-b px-4 py-3", children: _jsx(SheetTitle, { className: "text-sm", children: t('kanban.filters', { defaultValue: 'Filtros' }) }) }), _jsxs("div", { className: "flex flex-1 flex-col gap-1 overflow-y-auto px-4 py-3", children: [activeFields.length > 0 && (_jsxs(_Fragment, { children: [_jsx("p", { className: "px-0.5 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground", children: t('kanban.activeFilters', {
503
+ defaultValue: 'Con filtros activos',
504
+ }) }), activeFields.map((field) => (_jsx(SheetFilterRow, { field: field, isStage: field.key === groupByKey }, field.key))), inactiveFields.length > 0 && (_jsx("div", { className: "my-2 border-t" }))] })), inactiveFields.map((field) => (_jsx(SheetFilterRow, { field: field, isStage: field.key === groupByKey }, field.key)))] }), _jsxs("div", { className: "sticky bottom-0 flex items-center justify-between gap-2 border-t bg-background px-4 py-3", children: [_jsx("span", { className: "text-xs text-muted-foreground tabular-nums", children: activeFilterCount > 0
505
+ ? t('kanban.activeCount', {
506
+ defaultValue: '{{count}} activos',
507
+ count: activeFilterCount,
508
+ })
509
+ : t('kanban.noActiveFilters', {
510
+ defaultValue: 'Sin filtros',
511
+ }) }), _jsxs(Button, { variant: "ghost", size: "sm", className: "h-7 gap-1 text-xs", onClick: clearAll, disabled: activeFilterCount === 0, children: [_jsx(X, { className: "h-3.5 w-3.5" }), t('kanban.clearAll', { defaultValue: 'Limpiar todo' }), activeFilterCount > 0 ? ` (${activeFilterCount})` : ''] })] })] })] }))] }), activeFields.length > 0 && (_jsxs("div", { className: "flex flex-wrap items-center gap-1.5", "data-testid": "kanban-filter-chips", children: [activeFields.map((field) => {
512
+ const summary = summarizeFilterValues(field.config.selectedValues, field.config.options);
513
+ const dot = chipValueColor(field.config);
514
+ 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('kanban.removeFilter', {
515
+ defaultValue: 'Quitar filtro',
516
+ }), children: _jsx(X, { className: "h-3 w-3" }) })] }, field.key));
517
+ }), _jsx(Button, { variant: "ghost", size: "sm", className: "h-6 gap-1 px-2 text-xs text-muted-foreground", onClick: clearAll, children: t('kanban.clearAll', { defaultValue: 'Limpiar todo' }) })] })), _jsxs(DndContext, { sensors: sensors, onDragStart: onDragStart, onDragEnd: onDragEnd, children: [_jsx("div", { className: "flex min-w-0 gap-4 overflow-x-auto p-1", "data-testid": "kanban-board", children: lanes.map((stage) => {
366
518
  const allCards = grouped.get(stage.key) ?? [];
367
- // Per-lane client-side narrowing: substring match on the
368
- // chosen field over the already-fetched cards. Instant, and
369
- // scoped to this stage only.
519
+ // Per-lane client-side narrowing (instant, scoped to this
520
+ // stage). The funnel (field/value) and the lane search
521
+ // (query) are AND-combined.
370
522
  const laneFilter = laneFilters[stage.key];
371
- const cards = laneFilter && laneFilter.value.trim()
372
- ? allCards.filter((c) => String(c[laneFilter.field] ?? '')
373
- .toLowerCase()
374
- .includes(laneFilter.value.trim().toLowerCase()))
375
- : allCards;
523
+ let cards = allCards;
524
+ if (laneFilter?.field) {
525
+ cards = cards.filter((c) => cardMatchesLaneFunnel(c, laneFilter));
526
+ }
527
+ if (laneFilter?.query?.trim()) {
528
+ cards = cards.filter((c) => cardMatchesLaneQuery(c, searchCols, laneFilter.query));
529
+ }
376
530
  const droppableAllowed = !activeId ||
377
531
  stage.key === activeStage ||
378
532
  isTransitionAllowed(transitions, activeStage, stage.key);
379
- return (_jsx(KanbanLane, { stage: stage, count: cards.length, totalCount: allCards.length, filterFields: filterFields, laneFilter: laneFilter, onLaneFilterChange: (f) => setLaneFilter(stage.key, f), isDark: isDark, dimmed: !!activeId && !droppableAllowed, disabled: !!activeId && !droppableAllowed, children: loadingData && cards.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Skeleton, { className: "h-20 w-full" }), _jsx(Skeleton, { className: "h-20 w-full" })] })) : cards.length === 0 ? (_jsx("p", { className: "px-1 py-6 text-center text-xs text-muted-foreground", children: t('kanban.emptyLane', { defaultValue: 'Sin tarjetas' }) })) : (cards.map((card) => (_jsx(KanbanCard, { card: card, titleCol: titleCol, fieldCols: fieldCols, actions: rowActions, locale: i18n.language, timeZone: timeZone, currency: currency, onClick: onCardClick, onAction: handleInternalAction }, String(card.id))))) }, stage.key));
533
+ return (_jsx(KanbanLane, { stage: stage, count: cards.length, totalCount: allCards.length, filterFields: filterFields, laneFilter: laneFilter, onFunnelChange: (f) => updateLaneFilter(stage.key, {
534
+ field: f?.field,
535
+ values: f?.values,
536
+ text: f?.text,
537
+ }), onQueryChange: (q) => updateLaneFilter(stage.key, { query: q }), isDark: isDark, dimmed: !!activeId && !droppableAllowed, disabled: !!activeId && !droppableAllowed, children: loadingData && cards.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Skeleton, { className: "h-20 w-full" }), _jsx(Skeleton, { className: "h-20 w-full" })] })) : cards.length === 0 ? (_jsx("p", { className: "px-1 py-6 text-center text-xs text-muted-foreground", children: t('kanban.emptyLane', { defaultValue: 'Sin tarjetas' }) })) : (cards.map((card) => (_jsx(KanbanCard, { card: card, titleCol: titleCol, fieldCols: fieldCols, actions: rowActions, locale: i18n.language, timeZone: timeZone, currency: currency, onClick: onCardClick, onAction: handleInternalAction }, String(card.id))))) }, stage.key));
380
538
  }) }), _jsx(DragOverlay, { children: activeCard ? (_jsx(CardPreview, { card: activeCard, titleCol: titleCol, fieldCols: fieldCols, locale: i18n.language, timeZone: timeZone, currency: currency })) : null }), rowActionDialogs] })] }));
381
539
  }
382
- function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onLaneFilterChange, isDark, dimmed, disabled, children, }) {
540
+ /**
541
+ * A per-data-type glyph for the Filtros panel rows (and their popover header):
542
+ * Hash for numbers, Calendar for dates, CircleDot for the pipeline stage, Tag
543
+ * for value pickers, ToggleLeft for booleans, Type for free text.
544
+ */
545
+ function filterTypeIcon(filterType, isStage) {
546
+ if (isStage)
547
+ return _jsx(CircleDot, { className: "h-3.5 w-3.5" });
548
+ switch (filterType) {
549
+ case 'number_range':
550
+ return _jsx(Hash, { className: "h-3.5 w-3.5" });
551
+ case 'date_range':
552
+ return _jsx(Calendar, { className: "h-3.5 w-3.5" });
553
+ case 'boolean':
554
+ return _jsx(ToggleLeft, { className: "h-3.5 w-3.5" });
555
+ case 'select':
556
+ case 'dynamic_select':
557
+ case 'facet':
558
+ return _jsx(Tag, { className: "h-3.5 w-3.5" });
559
+ default:
560
+ return _jsx(Type, { className: "h-3.5 w-3.5" });
561
+ }
562
+ }
563
+ function SheetFilterRow({ field, isStage, }) {
564
+ const summary = summarizeFilterValues(field.config.selectedValues, field.config.options);
565
+ return (_jsx(ColumnFilterControl, { variant: "row", align: "end", icon: filterTypeIcon(field.config.filterType, isStage), label: field.label, valueSummary: summary, filterKey: field.config.filterKey, filterType: field.config.filterType, filterOptions: field.config.options, filterLoading: field.config.loading, filterSearchEndpoint: field.config.searchEndpoint, selectedValues: field.config.selectedValues, onFilterChange: field.config.onFilterChange, loadOptions: field.config.loadOptions }));
566
+ }
567
+ function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onFunnelChange, onQueryChange, isDark, dimmed, disabled, children, }) {
383
568
  const { t } = useTranslation();
384
569
  const { setNodeRef, isOver } = useDroppable({ id: stage.key, disabled });
385
570
  const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
386
571
  isDark,
387
572
  });
388
- const laneActive = !!(laneFilter && laneFilter.value.trim());
389
- const activeFieldLabel = filterFields.find((f) => f.key === laneFilter?.field)?.label ??
390
- laneFilter?.field;
391
- return (_jsxs("div", { ref: setNodeRef, className: "flex w-[300px] shrink-0 flex-col rounded-lg border bg-muted/30 transition-opacity", style: {
573
+ const funnelField = filterFields.find((f) => f.key === laneFilter?.field);
574
+ const funnelActive = !!(laneFilter?.field &&
575
+ ((laneFilter.values && laneFilter.values.length > 0) ||
576
+ laneFilter.text?.trim()));
577
+ const queryActive = !!laneFilter?.query?.trim();
578
+ const laneActive = funnelActive || queryActive;
579
+ const activeFieldLabel = funnelField?.label ?? laneFilter?.field;
580
+ // Human summary of the funnel value: resolved option labels for picked
581
+ // values, or the raw free text.
582
+ const funnelSummary = laneFilter?.values && laneFilter.values.length > 0
583
+ ? summarizeFilterValues(laneFilter.values, funnelField?.config?.options)
584
+ : laneFilter?.text ?? '';
585
+ // Inline lane search: a Search icon expands an Input; Escape or blur-while-
586
+ // empty collapses it. The query itself lives in the parent's laneFilters so
587
+ // it survives collapse and combines with the funnel.
588
+ const [searchOpen, setSearchOpen] = useState(queryActive);
589
+ const searchRef = useRef(null);
590
+ useEffect(() => {
591
+ if (searchOpen)
592
+ searchRef.current?.focus();
593
+ }, [searchOpen]);
594
+ const funnelValue = laneFilter?.field
595
+ ? {
596
+ field: laneFilter.field,
597
+ values: laneFilter.values,
598
+ text: laneFilter.text,
599
+ }
600
+ : undefined;
601
+ return (_jsxs("div", { ref: setNodeRef, className: "group/lane flex w-[300px] shrink-0 flex-col rounded-xl border bg-muted/30 transition-opacity", style: {
392
602
  opacity: dimmed ? 0.45 : 1,
393
603
  outline: isOver && !disabled ? '2px solid var(--ring, #3b82f6)' : 'none',
394
604
  outlineOffset: 2,
395
- }, "data-stage": stage.key, "data-disabled": disabled || undefined, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-2.5", children: [_jsx(Badge, { variant: "outline", className: "border-0 text-xs font-semibold", style: headerStyle, children: t(stage.label, { defaultValue: stage.label }) }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx("span", { className: "text-xs font-medium tabular-nums text-muted-foreground", children: laneActive ? `${count}/${totalCount}` : count }), _jsx(LaneFilterButton, { fields: filterFields, value: laneFilter, onChange: onLaneFilterChange })] })] }), laneActive && (_jsxs("div", { className: "flex items-center gap-1 px-3 pb-1.5 text-[11px] text-muted-foreground", children: [_jsx(ListFilter, { className: "h-3 w-3 shrink-0" }), _jsxs("span", { className: "truncate", children: [activeFieldLabel, ": ", laneFilter.value] }), _jsx("button", { type: "button", onClick: () => onLaneFilterChange(null), className: "ml-auto rounded p-0.5 hover:bg-muted", "aria-label": t('kanban.clearFilters', {
605
+ }, "data-stage": stage.key, "data-disabled": disabled || undefined, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-2.5", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(Badge, { variant: "outline", className: "border-0 text-xs font-semibold", style: headerStyle, children: t(stage.label, { defaultValue: stage.label }) }), _jsx("span", { className: "text-xs font-medium tabular-nums text-muted-foreground", children: laneActive ? `${count}/${totalCount}` : count })] }), _jsxs("div", { className: `flex items-center gap-0.5 transition-opacity focus-within:opacity-100 group-hover/lane:opacity-100 ${laneActive || searchOpen ? 'opacity-100' : 'opacity-0'}`, children: [_jsx("button", { type: "button", onClick: () => setSearchOpen((o) => !o), className: `flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${queryActive ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('kanban.searchLane', {
606
+ defaultValue: 'Buscar en la columna',
607
+ }), children: _jsx(Search, { className: "h-3.5 w-3.5" }) }), _jsx(LaneFilterButton, { fields: filterFields, value: funnelValue, onChange: onFunnelChange })] })] }), searchOpen && (_jsx("div", { className: "px-3 pb-1.5", children: _jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { ref: searchRef, value: laneFilter?.query ?? '', onChange: (e) => onQueryChange(e.target.value), onKeyDown: (e) => {
608
+ if (e.key === 'Escape') {
609
+ onQueryChange('');
610
+ setSearchOpen(false);
611
+ }
612
+ }, onBlur: () => {
613
+ if (!laneFilter?.query?.trim())
614
+ setSearchOpen(false);
615
+ }, placeholder: t('kanban.searchLanePlaceholder', {
616
+ defaultValue: 'Buscar tarjetas...',
617
+ }), className: "h-7 pl-7 text-xs" })] }) })), funnelActive && (_jsxs("div", { className: "flex items-center gap-1 px-3 pb-1.5 text-[11px] text-muted-foreground", children: [_jsx(ListFilter, { className: "h-3 w-3 shrink-0" }), _jsxs("span", { className: "truncate", children: [activeFieldLabel, ": ", funnelSummary] }), _jsx("button", { type: "button", onClick: () => onFunnelChange(null), className: "ml-auto rounded p-0.5 hover:bg-muted", "aria-label": t('kanban.clearFilters', {
396
618
  defaultValue: 'Limpiar',
397
619
  }), children: _jsx(X, { className: "h-3 w-3" }) })] })), _jsx("div", { className: "flex min-h-[55vh] max-h-[70vh] min-w-0 flex-col gap-2 overflow-y-auto px-2 pb-3", children: children })] }));
398
620
  }
@@ -403,33 +625,54 @@ function LaneFilterButton({ fields, value, onChange, }) {
403
625
  const { t } = useTranslation();
404
626
  const [open, setOpen] = useState(false);
405
627
  const [field, setField] = useState(value?.field ?? fields[0]?.key ?? '');
406
- const [text, setText] = useState(value?.value ?? '');
628
+ const [values, setValues] = useState(value?.values ?? []);
629
+ const [text, setText] = useState(value?.text ?? '');
407
630
  // Re-seed the draft from the committed filter each time the popover opens.
408
631
  useEffect(() => {
409
632
  if (open) {
410
633
  setField(value?.field ?? fields[0]?.key ?? '');
411
- setText(value?.value ?? '');
634
+ setValues(value?.values ?? []);
635
+ setText(value?.text ?? '');
412
636
  }
413
637
  }, [open, value, fields]);
414
638
  if (fields.length === 0)
415
639
  return null;
416
- const active = !!(value && value.value.trim());
640
+ const active = !!(value &&
641
+ ((value.values && value.values.length > 0) || value.text?.trim()));
642
+ // The value step mirrors the sheet: when the chosen field is a select or a
643
+ // facet (static options OR a lazy loader), render the SAME pro combobox —
644
+ // multi-select, searchable, with counts. Only a genuinely free-text field
645
+ // (no options, no loader) falls back to a raw "Contiene..." input.
646
+ const cfg = fields.find((f) => f.key === field)?.config;
647
+ const hasValuePicker = (cfg?.options?.length ?? 0) > 0 || !!cfg?.loadOptions;
648
+ const toggle = (v) => setValues((prev) => prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v]);
417
649
  const apply = () => {
418
- if (field && text.trim())
419
- onChange({ field, value: text });
650
+ if (field && values.length > 0)
651
+ onChange({ field, values });
652
+ else if (field && text.trim())
653
+ onChange({ field, text: text.trim() });
420
654
  else
421
655
  onChange(null);
422
656
  setOpen(false);
423
657
  };
424
- return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx("button", { type: "button", className: `rounded p-1 transition-colors hover:bg-muted hover:text-foreground ${active ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('kanban.filterLane', {
658
+ const clear = () => {
659
+ onChange(null);
660
+ setOpen(false);
661
+ };
662
+ return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx("button", { type: "button", className: `flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${active ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('kanban.filterLane', {
425
663
  defaultValue: 'Filtrar columna',
426
- }), children: _jsx(ListFilter, { className: "h-3.5 w-3.5" }) }) }), _jsxs(PopoverContent, { align: "end", className: "w-56 space-y-2 p-2", children: [_jsxs(Select, { value: field, onValueChange: setField, children: [_jsx(SelectTrigger, { className: "h-8 text-xs", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: fields.map((f) => (_jsx(SelectItem, { value: f.key, className: "text-xs", children: f.label }, f.key))) })] }), _jsx(Input, { autoFocus: true, value: text, onChange: (e) => setText(e.target.value), onKeyDown: (e) => {
664
+ }), children: _jsx(ListFilter, { className: "h-3.5 w-3.5" }) }) }), _jsxs(PopoverContent, { align: "end", className: "w-72 space-y-2.5 rounded-xl p-2.5 shadow-lg", children: [_jsxs(Select, { value: field, onValueChange: (f) => {
665
+ setField(f);
666
+ // Reset the value when switching fields — a value picked
667
+ // for one field is meaningless for another.
668
+ setValues([]);
669
+ setText('');
670
+ }, children: [_jsx(SelectTrigger, { className: "h-8 w-full text-xs", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: fields.map((f) => (_jsx(SelectItem, { value: f.key, className: "text-xs", children: f.label }, f.key))) })] }), hasValuePicker ? (_jsx("div", { className: "overflow-hidden rounded-lg border", children: _jsx(FilterValueCombobox, { staticOptions: cfg?.options, loadOptions: cfg?.loadOptions, selected: values, onToggle: toggle }, field) })) : (_jsx(Input, { autoFocus: true, value: text, onChange: (e) => setText(e.target.value), onKeyDown: (e) => {
427
671
  if (e.key === 'Enter')
428
672
  apply();
429
- }, placeholder: t('kanban.filterValue', { defaultValue: 'Valor...' }), className: "h-8 text-xs" }), _jsxs("div", { className: "flex justify-between gap-2", children: [_jsx(Button, { variant: "ghost", size: "sm", className: "h-7 text-xs", onClick: () => {
430
- onChange(null);
431
- setOpen(false);
432
- }, children: t('kanban.clearFilters', { defaultValue: 'Limpiar' }) }), _jsx(Button, { size: "sm", className: "h-7 text-xs", onClick: apply, children: t('kanban.apply', { defaultValue: 'Aplicar' }) })] })] })] }));
673
+ }, placeholder: t('kanban.filterValue', {
674
+ defaultValue: 'Contiene...',
675
+ }), className: "h-8 w-full text-xs" })), _jsxs("div", { className: "flex gap-1.5", children: [_jsx(Button, { variant: "outline", size: "sm", className: "h-7 flex-1 text-xs", onClick: clear, disabled: !active && values.length === 0 && !text.trim(), children: t('kanban.clearFilters', { defaultValue: 'Limpiar' }) }), _jsxs(Button, { size: "sm", className: "h-7 flex-1 text-xs", onClick: apply, disabled: values.length === 0 && !text.trim(), children: [t('kanban.apply', { defaultValue: 'Aplicar' }), values.length > 0 ? ` (${values.length})` : ''] })] })] })] }));
433
676
  }
434
677
  function KanbanCard({ card, titleCol, fieldCols, actions, locale, timeZone, currency, onClick, onAction, }) {
435
678
  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAgC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAUnF,MAAM,WAAW,iBAAiB;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC/B,cAAc,CAAC,EAAE,GAAG,CAAA;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACpC,YAAY,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAA;IAC/B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,aAAoB,EACpB,aAAkB,EAClB,QAAQ,EACR,UAAU,EACV,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,EAC5C,QAAQ,EACR,QAAQ,GACX,EAAE,iBAAiB,+BA2zBnB"}
1
+ {"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAgC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAWnF,MAAM,WAAW,iBAAiB;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC/B,cAAc,CAAC,EAAE,GAAG,CAAA;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACpC,YAAY,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAA;IAC/B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,aAAoB,EACpB,aAAkB,EAClB,QAAQ,EACR,UAAU,EACV,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,EAC5C,QAAQ,EACR,QAAQ,GACX,EAAE,iBAAiB,+BA02BnB"}
@@ -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,CAyTzB"}