@asteby/metacore-runtime-react 23.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 CHANGED
@@ -1,5 +1,34 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 23.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 08e18bf: Paridad de la experiencia pro de filtros en DynamicTable (vista tabla de los
8
+ módulos dinámicos), igualando al kanban.
9
+ - **Prefetch de facetas en la tabla:** al resolver la metadata se precargan en
10
+ paralelo (dedup por firma, `allSettled`) las facetas de todos los campos facet,
11
+ sembrando sus `options`. Abrir el filtro de una columna de texto en el header
12
+ ya muestra el combobox con valores + counts al instante, sin "Cargando…"; el
13
+ spinner queda solo para el refetch con búsqueda.
14
+ - **i18n de opciones en la tabla:** las opciones (stages y cualquier key i18n del
15
+ manifest) se traducen con `t(label, {defaultValue})` en runtime-react antes de
16
+ pasarlas al ui package, tanto en los filtros de header como en los chips y
17
+ resúmenes de valor.
18
+ - **Fila de chips de filtros activos sobre la tabla:** debajo de la toolbar,
19
+ removibles ("Campo: valor(es) ×" + "Limpiar todo"), con el color del valor
20
+ cuando aplica (p.ej. la etapa). Extraído a un componente compartido
21
+ `FilterChipsRow` (con `summarizeFilterValues`/`chipValueColor`/
22
+ `translateOptionLabels`) reusado por el kanban y la tabla — sin duplicar.
23
+ - **Stage-select en la tabla:** la columna `group_by`/stage sin opciones propias
24
+ ofrece el select con las etapas del pipeline (traducidas, con color), no un
25
+ cuadro de texto — sale del motor compartido; confirmado con test.
26
+ - **Header de lane del kanban:** los botones de búsqueda y embudo se ven SIEMPRE
27
+ (se quitó el hover-reveal, que nadie descubría) en muted con hover a foreground.
28
+ El embudo muestra un badge numérico con la cantidad de valores/filtros
29
+ aplicados en esa lane (como el badge del botón Filtros de la toolbar); la lupa
30
+ tiñe a primary + dot cuando hay búsqueda activa.
31
+
3
32
  ## 23.0.0
4
33
 
5
34
  ### Patch Changes
@@ -1,5 +1,7 @@
1
1
  import * as React from 'react';
2
+ import { summarizeFilterValues, translateOptionLabels } from './filter-chips';
2
3
  import type { TableMetadata, ColumnDefinition, StageMeta, StageTransition } from './types';
4
+ export { summarizeFilterValues, translateOptionLabels };
3
5
  /**
4
6
  * Resolves the board lanes for a kanban view. Prefers the model-level
5
7
  * `metadata.stages` (the kernel's `stages[]`); falls back to the `group_by`
@@ -40,16 +42,6 @@ export declare function selectCardColumns(metadata: TableMetadata, maxFields?: n
40
42
  title: ColumnDefinition | null;
41
43
  fields: ColumnDefinition[];
42
44
  };
43
- /**
44
- * Human-readable summary of a field's selected filter values for the removable
45
- * chip row. Resolves option labels, unwraps the wire operators
46
- * (`IN:`/`ILIKE:`/`RANGE:`/`GTE:`/`LTE:`/date `from_to`), and caps the list at
47
- * `maxShown` values with a `+n` overflow. Pure — exported for unit tests.
48
- */
49
- export declare function summarizeFilterValues(values: string[] | undefined, options: {
50
- label: string;
51
- value: string;
52
- }[] | undefined, maxShown?: number): string;
53
45
  /**
54
46
  * Whether a card passes a lane funnel. Picked select/facet `values` match by
55
47
  * equality (IN — the card's field value must be one of them); a free-text
@@ -62,13 +54,14 @@ export declare function cardMatchesLaneFunnel(card: any, filter: {
62
54
  text?: string;
63
55
  } | undefined): boolean;
64
56
  /**
65
- * Translates option labels through the app translator (manifest i18n keys
66
- * localized text). A raw value with no matching key falls through to itself via
67
- * `defaultValue`. Pure — exported for unit tests.
57
+ * Count of applied criteria on a lane funnel: the number of picked select/facet
58
+ * `values`, else 1 for a free-text `text`, else 0. Drives the funnel's count
59
+ * badge. Pure — exported for unit tests.
68
60
  */
69
- export declare function translateOptionLabels<T extends {
70
- label: string;
71
- }>(options: T[], translate: (key: string) => string): T[];
61
+ export declare function laneFunnelCount(value: {
62
+ values?: string[];
63
+ text?: string;
64
+ } | undefined): number;
72
65
  /**
73
66
  * Whether a card matches a free-text lane search: a case-insensitive substring
74
67
  * over the card's title + every visible field value (`String(v)`). Empty query
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-kanban.d.ts","sourceRoot":"","sources":["../src/dynamic-kanban.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"dynamic-kanban.d.ts","sourceRoot":"","sources":["../src/dynamic-kanban.tsx"],"names":[],"mappings":"AA0BA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAwD9B,OAAO,EAEH,qBAAqB,EACrB,qBAAqB,EACxB,MAAM,gBAAgB,CAAA;AAQvB,OAAO,KAAK,EACR,aAAa,EACb,gBAAgB,EAGhB,SAAS,EACT,eAAe,EAClB,MAAM,SAAS,CAAA;AAIhB,OAAO,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,CAAA;AAMvD;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,aAAa,GAAG,SAAS,EAAE,CAejE;AAQD;;;;;GAKG;AACH,eAAO,MAAM,eAAe,mBAAmB,CAAA;AAE/C,wBAAgB,YAAY,CACxB,OAAO,EAAE,GAAG,EAAE,EACd,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,SAAS,EAAE,GACpB,GAAG,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,CAepB;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAC/B,WAAW,EAAE,eAAe,EAAE,GAAG,SAAS,EAC1C,IAAI,EAAE,MAAM,EACZ,EAAE,EAAE,MAAM,GACX,OAAO,CAOT;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAC/B,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,EAC3B,MAAM,EAAE,MAAM,GAAG,MAAM,EACvB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,GACnB,GAAG,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,CAYpB;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC7B,QAAQ,EAAE,aAAa,EACvB,SAAS,SAAI,GACd;IAAE,KAAK,EAAE,gBAAgB,GAAG,IAAI,CAAC;IAAC,MAAM,EAAE,gBAAgB,EAAE,CAAA;CAAE,CAkBhE;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACjC,IAAI,EAAE,GAAG,EACT,MAAM,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,GACzE,OAAO,CAUT;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC3B,KAAK,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,GACxD,MAAM,CAIR;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAChC,IAAI,EAAE,GAAG,EACT,IAAI,EAAE,gBAAgB,EAAE,EACxB,KAAK,EAAE,MAAM,GACd,OAAO,CAQT;AA4CD,MAAM,WAAW,kBAAkB;IAC/B,6DAA6D;IAC7D,KAAK,EAAE,MAAM,CAAA;IACb;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,kFAAkF;IAClF,cAAc,CAAC,EAAE,GAAG,CAAA;IACpB,+DAA+D;IAC/D,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAChC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,4DAA4D;IAC5D,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CACvC;AAED,wBAAgB,aAAa,CAAC,EAC1B,KAAK,EACL,QAAQ,EACR,cAAc,EACd,WAAW,EACX,QAAQ,EACR,QAAc,EACd,QAAQ,EACR,QAAQ,EACR,cAAc,GACjB,EAAE,kBAAkB,qBA8hBpB"}
@@ -6,9 +6,10 @@ import { Calendar, CircleDot, Hash, ListFilter, MoreHorizontal, Search, Tag, Tog
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
8
  import { ColumnFilterControl, FilterValueCombobox } from '@asteby/metacore-ui/data-table';
9
- import { generateBadgeStyles, optionColor, resolveColorCss } from '@asteby/metacore-ui/lib';
9
+ import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib';
10
10
  import { useApi } from './api-context';
11
11
  import { useDynamicFilters } from './use-dynamic-filters';
12
+ import { FilterChipsRow, summarizeFilterValues, translateOptionLabels, } from './filter-chips';
12
13
  import { useMetadataCache } from './metadata-cache';
13
14
  import { ActivityValueRenderer } from './activity-value-renderer';
14
15
  import { DynamicIcon } from './dynamic-icon';
@@ -16,6 +17,9 @@ import { isColumnVisibleInTable } from './column-visibility';
16
17
  import { isRowActionVisible } from './dynamic-columns';
17
18
  import { useCan, usePermissionsActive, resolveRowActions } from './permissions-context';
18
19
  import { useDynamicRowActions } from './dynamic-row-actions';
20
+ // Re-exported for tests + backward-compat: these live in ./filter-chips now
21
+ // (shared with DynamicTable) but were historically imported from here.
22
+ export { summarizeFilterValues, translateOptionLabels };
19
23
  // ---------------------------------------------------------------------------
20
24
  // Pure helpers (exported for unit tests — no React, no transport)
21
25
  // ---------------------------------------------------------------------------
@@ -129,64 +133,6 @@ export function selectCardColumns(metadata, maxFields = 3) {
129
133
  .slice(0, maxFields);
130
134
  return { title, fields };
131
135
  }
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
136
  /**
191
137
  * Whether a card passes a lane funnel. Picked select/facet `values` match by
192
138
  * equality (IN — the card's field value must be one of them); a free-text
@@ -206,12 +152,16 @@ export function cardMatchesLaneFunnel(card, filter) {
206
152
  return true;
207
153
  }
208
154
  /**
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.
155
+ * Count of applied criteria on a lane funnel: the number of picked select/facet
156
+ * `values`, else 1 for a free-text `text`, else 0. Drives the funnel's count
157
+ * badge. Pure — exported for unit tests.
212
158
  */
213
- export function translateOptionLabels(options, translate) {
214
- return options.map((o) => ({ ...o, label: translate(o.label) }));
159
+ export function laneFunnelCount(value) {
160
+ if (value?.values?.length)
161
+ return value.values.length;
162
+ if (value?.text?.trim())
163
+ return 1;
164
+ return 0;
215
165
  }
216
166
  /**
217
167
  * Whether a card matches a free-text lane search: a case-insensitive substring
@@ -508,13 +458,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
508
458
  })
509
459
  : t('kanban.noActiveFilters', {
510
460
  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) => {
461
+ }) }), _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})` : ''] })] })] })] }))] }), _jsx(FilterChipsRow, { fields: activeFields, onClearAll: clearAll, "data-testid": "kanban-filter-chips" }), _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) => {
518
462
  const allCards = grouped.get(stage.key) ?? [];
519
463
  // Per-lane client-side narrowing (instant, scoped to this
520
464
  // stage). The funnel (field/value) and the lane search
@@ -602,9 +546,9 @@ function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onFunn
602
546
  opacity: dimmed ? 0.45 : 1,
603
547
  outline: isOver && !disabled ? '2px solid var(--ring, #3b82f6)' : 'none',
604
548
  outlineOffset: 2,
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', {
549
+ }, "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", children: [_jsxs("button", { type: "button", onClick: () => setSearchOpen((o) => !o), className: `relative 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
550
  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) => {
551
+ }), children: [_jsx(Search, { className: "h-3.5 w-3.5" }), queryActive && (_jsx("span", { className: "absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-primary" }))] }), _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
552
  if (e.key === 'Escape') {
609
553
  onQueryChange('');
610
554
  setSearchOpen(false);
@@ -639,6 +583,8 @@ function LaneFilterButton({ fields, value, onChange, }) {
639
583
  return null;
640
584
  const active = !!(value &&
641
585
  ((value.values && value.values.length > 0) || value.text?.trim()));
586
+ // Number of applied criteria on this lane's funnel (drives the count badge).
587
+ const activeCount = laneFunnelCount(value);
642
588
  // The value step mirrors the sheet: when the chosen field is a select or a
643
589
  // facet (static options OR a lazy loader), render the SAME pro combobox —
644
590
  // multi-select, searchable, with counts. Only a genuinely free-text field
@@ -659,9 +605,9 @@ function LaneFilterButton({ fields, value, onChange, }) {
659
605
  onChange(null);
660
606
  setOpen(false);
661
607
  };
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', {
608
+ return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { type: "button", className: `relative 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', {
663
609
  defaultValue: 'Filtrar columna',
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) => {
610
+ }), children: [_jsx(ListFilter, { className: "h-3.5 w-3.5" }), activeCount > 0 && (_jsx("span", { className: "absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-0.5 text-[9px] font-bold leading-none text-primary-foreground tabular-nums", children: activeCount }))] }) }), _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
611
  setField(f);
666
612
  // Reset the value when switching fields — a value picked
667
613
  // for one field is meaningless for another.
@@ -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;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"}
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;AAYnF,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,+BAw6BnB"}
@@ -25,6 +25,7 @@ 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
27
  import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders';
28
+ import { FilterChipsRow, translateOptionLabels } from './filter-chips';
28
29
  import { OptionsContext } from './options-context';
29
30
  import { getSearchableColumnKeys } from './column-visibility';
30
31
  import { useCan, usePermissionsActive, gateTableMetadata } from './permissions-context';
@@ -462,11 +463,15 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
462
463
  // getDynamicColumns forwards `loadOptions` into the column meta). Facets base
463
464
  // derived off the list endpoint exactly like the aggregate endpoint below.
464
465
  const facetsBase = endpoint ? `${endpoint}/facets` : model ? `/data/${model}/facets` : null;
465
- const { getFacetLoader } = useFacetLoaders(facetsBase);
466
+ const { getFacetLoader, prefetchFacets, facetOptions } = useFacetLoaders(facetsBase);
466
467
  const columnFilterConfigs = useMemo(() => {
467
468
  const map = new Map();
468
469
  if (!metadata)
469
470
  return map;
471
+ // Option labels arrive as manifest i18n keys; translate them here (the ui
472
+ // package has no i18n) so header filters, chips and value summaries show
473
+ // localized text. A raw value with no key falls through via defaultValue.
474
+ const tr = (label) => t(label, { defaultValue: label });
470
475
  const stageOptions = (metadata.stages ?? []).map((s) => ({
471
476
  label: s.label,
472
477
  value: s.key,
@@ -500,6 +505,9 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
500
505
  if (loader) {
501
506
  fType = 'facet';
502
507
  loadOptions = loader;
508
+ // Prewarmed values (from prefetchFacets) → the header filter
509
+ // opens with the list already there, no "Cargando…" flash.
510
+ options = facetOptions.get(f.column || f.key) ?? [];
503
511
  }
504
512
  }
505
513
  if (fType === 'select' && options.length === 0 && !f.searchEndpoint)
@@ -507,12 +515,14 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
507
515
  map.set(f.key, {
508
516
  filterType: fType,
509
517
  filterKey: f.column || f.key,
510
- options,
518
+ options: translateOptionLabels(options, tr),
511
519
  selectedValues: dynamicFilters[f.column || f.key] || [],
512
520
  onFilterChange: handleDynamicFilterChange,
513
521
  loading: f.searchEndpoint ? !filterOptionsMap.has(f.searchEndpoint) : false,
514
522
  searchEndpoint: f.searchEndpoint,
515
- loadOptions,
523
+ loadOptions: loadOptions
524
+ ? (q) => loadOptions(q).then((o) => translateOptionLabels(o, tr))
525
+ : undefined,
516
526
  });
517
527
  }
518
528
  for (const c of metadata.columns ?? []) {
@@ -569,21 +579,64 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
569
579
  if (loader) {
570
580
  filterType = 'facet';
571
581
  loadOptions = loader;
582
+ // Prewarmed values (from prefetchFacets) → instant open.
583
+ options = facetOptions.get(c.key) ?? [];
572
584
  }
573
585
  }
574
586
  map.set(c.key, {
575
587
  filterType,
576
588
  filterKey: c.key,
577
- options,
589
+ options: translateOptionLabels(options, tr),
578
590
  selectedValues: dynamicFilters[c.key] || [],
579
591
  onFilterChange: handleDynamicFilterChange,
580
592
  loading: hasEndpoint && !filterOptionsMap.has(c.searchEndpoint),
581
593
  searchEndpoint: c.searchEndpoint,
582
- loadOptions,
594
+ loadOptions: loadOptions
595
+ ? (q) => loadOptions(q).then((o) => translateOptionLabels(o, tr))
596
+ : undefined,
583
597
  });
584
598
  }
585
599
  return map;
586
- }, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange, facetsBase, getFacetLoader]);
600
+ }, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange, facetsBase, getFacetLoader, facetOptions, t]);
601
+ // Prewarm every facet field once the configs settle, so a text column's
602
+ // header filter opens instantly with values + counts (same as the kanban).
603
+ const facetFieldsSig = useMemo(() => {
604
+ const keys = [];
605
+ for (const config of columnFilterConfigs.values()) {
606
+ if (config.filterType === 'facet')
607
+ keys.push(config.filterKey);
608
+ }
609
+ return keys.join('|');
610
+ }, [columnFilterConfigs]);
611
+ useEffect(() => {
612
+ if (!facetFieldsSig)
613
+ return;
614
+ prefetchFacets(facetFieldsSig.split('|'));
615
+ }, [facetFieldsSig, prefetchFacets]);
616
+ // Active-filter chips (shared row with the kanban). One chip per field with a
617
+ // selection; the label is the translated filter/column label.
618
+ const activeFilterChips = useMemo(() => {
619
+ if (!metadata)
620
+ return [];
621
+ const out = [];
622
+ for (const [key, config] of columnFilterConfigs) {
623
+ if ((config.selectedValues?.length ?? 0) === 0)
624
+ continue;
625
+ const f = metadata.filters?.find((x) => x.key === key);
626
+ const c = metadata.columns?.find((x) => x.key === key);
627
+ const rawLabel = f?.label || c?.label || key;
628
+ out.push({
629
+ key,
630
+ label: t(rawLabel, { defaultValue: rawLabel }),
631
+ config,
632
+ });
633
+ }
634
+ return out;
635
+ }, [metadata, columnFilterConfigs, t]);
636
+ const clearAllDynamicFilters = useCallback(() => {
637
+ setDynamicFilters({});
638
+ setPagination((prev) => ({ ...prev, pageIndex: 0 }));
639
+ }, []);
587
640
  const columns = useMemo(() => {
588
641
  if (!viewMetadata)
589
642
  return [];
@@ -629,7 +682,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
629
682
  if (!metadata) {
630
683
  return _jsx("div", { className: "text-center text-muted-foreground py-8", children: "Error al cargar la configuraci\u00F3n de la tabla." });
631
684
  }
632
- return (_jsxs(OptionsContext.Provider, { value: { optionsMap }, children: [_jsxs("div", { className: 'flex flex-col h-full min-h-0 w-full', children: [_jsx("div", { className: 'pb-4 shrink-0', children: _jsx(DataTableToolbar, { table: table, searchPlaceholder: metadata.searchPlaceholder || 'Buscar...', filters: filters, activeFilters: dynamicFilters, onDynamicFilterChange: handleDynamicFilterChange, dateFilter: { value: dateRange, onChange: setDateRange, placeholder: 'Filtrar por fecha' }, perPageOptions: metadata.perPageOptions, onRefresh: handleRefresh, isLoading: loadingData, selectedCount: Object.keys(rowSelection).length, onBulkDelete: () => setShowBulkDeleteConfirm(true), extraActions: _jsxs(_Fragment, { children: [viewMetadata?.canExport && (_jsxs(Button, { variant: "outline", size: "sm", className: "h-8", onClick: () => setExportOpen(true), children: [_jsx(Download, { className: "h-4 w-4 mr-1" }), " Exportar"] })), viewMetadata?.canImport && (_jsxs(Button, { variant: "outline", size: "sm", className: "h-8", onClick: () => setImportOpen(true), children: [_jsx(Upload, { className: "h-4 w-4 mr-1" }), " Importar"] }))] }) }) }), _jsx("div", { className: 'hidden sm:block flex-1 min-h-0 overflow-auto border rounded-md bg-card', children: _jsxs(Table, { noWrapper: true, className: cn('min-w-max w-full', aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && 'h-full'), children: [_jsx(TableHeader, { className: 'sticky top-0 z-10', children: table.getHeaderGroups().map((headerGroup) => (_jsx(TableRow, { className: 'border-b-0 hover:bg-transparent', children: headerGroup.headers.map((header) => {
685
+ return (_jsxs(OptionsContext.Provider, { value: { optionsMap }, children: [_jsxs("div", { className: 'flex flex-col h-full min-h-0 w-full', children: [_jsxs("div", { className: 'pb-4 shrink-0', children: [_jsx(DataTableToolbar, { table: table, searchPlaceholder: metadata.searchPlaceholder || 'Buscar...', filters: filters, activeFilters: dynamicFilters, onDynamicFilterChange: handleDynamicFilterChange, dateFilter: { value: dateRange, onChange: setDateRange, placeholder: 'Filtrar por fecha' }, perPageOptions: metadata.perPageOptions, onRefresh: handleRefresh, isLoading: loadingData, selectedCount: Object.keys(rowSelection).length, onBulkDelete: () => setShowBulkDeleteConfirm(true), extraActions: _jsxs(_Fragment, { children: [viewMetadata?.canExport && (_jsxs(Button, { variant: "outline", size: "sm", className: "h-8", onClick: () => setExportOpen(true), children: [_jsx(Download, { className: "h-4 w-4 mr-1" }), " Exportar"] })), viewMetadata?.canImport && (_jsxs(Button, { variant: "outline", size: "sm", className: "h-8", onClick: () => setImportOpen(true), children: [_jsx(Upload, { className: "h-4 w-4 mr-1" }), " Importar"] }))] }) }), activeFilterChips.length > 0 && (_jsx("div", { className: 'pt-2', children: _jsx(FilterChipsRow, { fields: activeFilterChips, onClearAll: clearAllDynamicFilters, "data-testid": 'table-filter-chips' }) }))] }), _jsx("div", { className: 'hidden sm:block flex-1 min-h-0 overflow-auto border rounded-md bg-card', children: _jsxs(Table, { noWrapper: true, className: cn('min-w-max w-full', aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && 'h-full'), children: [_jsx(TableHeader, { className: 'sticky top-0 z-10', children: table.getHeaderGroups().map((headerGroup) => (_jsx(TableRow, { className: 'border-b-0 hover:bg-transparent', children: headerGroup.headers.map((header) => {
633
686
  const isActionsColumn = header.id === 'actions';
634
687
  return (_jsx(TableHead, { colSpan: header.colSpan, style: header.column.columnDef.size ? { width: header.column.columnDef.size } : undefined, className: cn('bg-card border-b h-10', isActionsColumn && 'sticky right-0 z-20 bg-card shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.1)]'), children: header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext()) }, header.id));
635
688
  }) }, headerGroup.id))) }), _jsx(TableBody, { children: loadingData && data.length === 0 ? (_jsx(TableSkeleton, {})) : table.getRowModel().rows?.length ? (_jsxs(_Fragment, { children: [table.getRowModel().rows.map((row) => (_jsx(TableRow, { "data-state": row.getIsSelected() && 'selected', className: cn(onRowClick && 'cursor-pointer'), onClick: onRowClick ? () => onRowClick(row.original) : undefined, children: row.getVisibleCells().map((cell) => {
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "23.0.0",
3
+ "version": "23.1.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -67,8 +67,8 @@
67
67
  "typescript": "^6.0.0",
68
68
  "vitest": "^4.0.0",
69
69
  "zustand": "^5.0.0",
70
- "@asteby/metacore-ui": "2.9.0",
71
- "@asteby/metacore-sdk": "3.2.0"
70
+ "@asteby/metacore-sdk": "3.2.0",
71
+ "@asteby/metacore-ui": "2.9.0"
72
72
  },
73
73
  "scripts": {
74
74
  "build": "tsc -p tsconfig.json",