@asteby/metacore-runtime-react 22.0.0 → 23.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/dist/dynamic-kanban.d.ts +20 -8
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +103 -102
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +60 -7
- package/dist/filter-chips.d.ts +60 -0
- package/dist/filter-chips.d.ts.map +1 -0
- package/dist/filter-chips.js +93 -0
- package/dist/use-dynamic-filters.d.ts.map +1 -1
- package/dist/use-dynamic-filters.js +25 -1
- package/dist/use-facet-loaders.d.ts +23 -7
- package/dist/use-facet-loaders.d.ts.map +1 -1
- package/dist/use-facet-loaders.js +49 -8
- package/package.json +3 -3
- package/src/__tests__/dynamic-kanban.test.tsx +141 -0
- package/src/__tests__/dynamic-table-filters.test.tsx +102 -0
- package/src/__tests__/filter-chips.test.tsx +119 -0
- package/src/__tests__/use-dynamic-filters.test.tsx +65 -4
- package/src/dynamic-kanban.tsx +189 -199
- package/src/dynamic-table.tsx +69 -6
- package/src/filter-chips.tsx +175 -0
- package/src/use-dynamic-filters.ts +26 -1
- package/src/use-facet-loaders.ts +76 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,67 @@
|
|
|
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
|
+
|
|
32
|
+
## 23.0.0
|
|
33
|
+
|
|
34
|
+
### Patch Changes
|
|
35
|
+
|
|
36
|
+
- 25a78e7: Fixes del embudo por columna del kanban (LaneFilterButton).
|
|
37
|
+
- **Precarga de opciones dinámicas:** el value-picker del embudo ahora usa el
|
|
38
|
+
MISMO combobox pro que el Sheet — si el campo elegido tiene options estáticas
|
|
39
|
+
o `loadOptions` (facet), renderiza el combobox multi-select con carga lazy
|
|
40
|
+
(counts, buscador "Buscar valores...", estados de carga). Antes un campo facet
|
|
41
|
+
(p.ej. "Repo") caía al input crudo "Valor..." porque sus options aún no
|
|
42
|
+
estaban cargadas. Solo cae a input de texto cuando el campo es texto libre sin
|
|
43
|
+
facets. Nuevo componente compartible `FilterValueCombobox` en
|
|
44
|
+
`@asteby/metacore-ui`. El filtrado del lane sigue client-side: igualdad/IN
|
|
45
|
+
para valores de select/facet, substring para texto libre.
|
|
46
|
+
- **Traducción de opciones:** las opciones de Stage (y cualquier option con key
|
|
47
|
+
i18n del manifest) se traducen con `t(label, {defaultValue})` en runtime-react
|
|
48
|
+
ANTES de pasarlas al control (ColumnFilterControl vive en ui, sin i18n). Aplica
|
|
49
|
+
al embudo, al Sheet, a los chips y a los resúmenes de valor; el valor activo de
|
|
50
|
+
stage se muestra traducido y con su color.
|
|
51
|
+
- **Diseño del popover del embudo:** select de campo y control de valor a ancho
|
|
52
|
+
completo, popover más ancho (`w-72`), combobox con borde redondeado, botones
|
|
53
|
+
Limpiar/Aplicar full-width en fila — al nivel del resto del rediseño.
|
|
54
|
+
- **Prefetch de facetas:** al resolver la metadata se precargan en paralelo las
|
|
55
|
+
facetas de TODOS los campos facet (un `api.get` por campo, dedupeado por firma,
|
|
56
|
+
`allSettled` para que un campo con error no rompa el resto), sembrando el cache
|
|
57
|
+
del loader y poblando las `options` del `ColumnFilterConfig`. Así el popover
|
|
58
|
+
del Sheet y el value-picker del lane abren instantáneos con valores + counts,
|
|
59
|
+
sin "Cargando…"; el spinner queda solo para el refetch con búsqueda (`q`).
|
|
60
|
+
`useFacetLoaders` ahora expone `prefetchFacets` y `facetOptions`.
|
|
61
|
+
|
|
62
|
+
- Updated dependencies [25a78e7]
|
|
63
|
+
- @asteby/metacore-ui@2.9.0
|
|
64
|
+
|
|
3
65
|
## 22.0.0
|
|
4
66
|
|
|
5
67
|
### Minor Changes
|
package/dist/dynamic-kanban.d.ts
CHANGED
|
@@ -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`
|
|
@@ -41,15 +43,25 @@ export declare function selectCardColumns(metadata: TableMetadata, maxFields?: n
|
|
|
41
43
|
fields: ColumnDefinition[];
|
|
42
44
|
};
|
|
43
45
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
46
|
+
* Whether a card passes a lane funnel. Picked select/facet `values` match by
|
|
47
|
+
* equality (IN — the card's field value must be one of them); a free-text
|
|
48
|
+
* `text` matches by case-insensitive substring. No field / no criteria → passes.
|
|
49
|
+
* Pure — exported for unit tests.
|
|
48
50
|
*/
|
|
49
|
-
export declare function
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
export declare function cardMatchesLaneFunnel(card: any, filter: {
|
|
52
|
+
field?: string;
|
|
53
|
+
values?: string[];
|
|
54
|
+
text?: string;
|
|
55
|
+
} | undefined): boolean;
|
|
56
|
+
/**
|
|
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.
|
|
60
|
+
*/
|
|
61
|
+
export declare function laneFunnelCount(value: {
|
|
62
|
+
values?: string[];
|
|
63
|
+
text?: string;
|
|
64
|
+
} | undefined): number;
|
|
53
65
|
/**
|
|
54
66
|
* Whether a card matches a free-text lane search: a case-insensitive substring
|
|
55
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":"AA0BA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-kanban.d.ts","sourceRoot":"","sources":["../src/dynamic-kanban.tsx"],"names":[],"mappings":"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"}
|
package/dist/dynamic-kanban.js
CHANGED
|
@@ -5,10 +5,11 @@ import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors, useDragg
|
|
|
5
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
|
|
8
|
+
import { ColumnFilterControl, FilterValueCombobox } from '@asteby/metacore-ui/data-table';
|
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -130,62 +134,34 @@ export function selectCardColumns(metadata, maxFields = 3) {
|
|
|
130
134
|
return { title, fields };
|
|
131
135
|
}
|
|
132
136
|
/**
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
+
* Whether a card passes a lane funnel. Picked select/facet `values` match by
|
|
138
|
+
* equality (IN — the card's field value must be one of them); a free-text
|
|
139
|
+
* `text` matches by case-insensitive substring. No field / no criteria → passes.
|
|
140
|
+
* Pure — exported for unit tests.
|
|
137
141
|
*/
|
|
138
|
-
export function
|
|
139
|
-
if (!
|
|
140
|
-
return
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
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('_', ' – ');
|
|
142
|
+
export function cardMatchesLaneFunnel(card, filter) {
|
|
143
|
+
if (!filter?.field)
|
|
144
|
+
return true;
|
|
145
|
+
const raw = String(card?.[filter.field] ?? '');
|
|
146
|
+
if (filter.values && filter.values.length > 0) {
|
|
147
|
+
return filter.values.includes(raw);
|
|
156
148
|
}
|
|
157
|
-
if (
|
|
158
|
-
|
|
159
|
-
const max = values.find((v) => v.startsWith('LTE:'))?.slice(4) ?? '';
|
|
160
|
-
return `${min || '…'} – ${max || '…'}`;
|
|
149
|
+
if (filter.text?.trim()) {
|
|
150
|
+
return raw.toLowerCase().includes(filter.text.trim().toLowerCase());
|
|
161
151
|
}
|
|
162
|
-
return
|
|
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}`;
|
|
152
|
+
return true;
|
|
169
153
|
}
|
|
170
154
|
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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.
|
|
174
158
|
*/
|
|
175
|
-
function
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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;
|
|
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;
|
|
189
165
|
}
|
|
190
166
|
/**
|
|
191
167
|
* Whether a card matches a free-text lane search: a case-insensitive substring
|
|
@@ -308,11 +284,29 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
308
284
|
if (!metadata)
|
|
309
285
|
return [];
|
|
310
286
|
const out = [];
|
|
287
|
+
// Option labels come from the manifest as i18n keys (e.g.
|
|
288
|
+
// "integration_github.stage.backlog"). ColumnFilterControl lives in the
|
|
289
|
+
// ui package (no i18n), so translate labels HERE — on the static options
|
|
290
|
+
// and on whatever the facet loader resolves — before they ever reach a
|
|
291
|
+
// control, chip or value summary. A raw value (a repo name) has no key,
|
|
292
|
+
// so t() returns it verbatim via defaultValue.
|
|
293
|
+
const tr = (label) => t(label, { defaultValue: label });
|
|
311
294
|
for (const [key, config] of columnFilterConfigs) {
|
|
312
295
|
const f = metadata.filters?.find((x) => x.key === key);
|
|
313
296
|
const c = metadata.columns.find((x) => x.key === key);
|
|
314
297
|
const rawLabel = f?.label || c?.label || key;
|
|
315
|
-
|
|
298
|
+
const translatedConfig = {
|
|
299
|
+
...config,
|
|
300
|
+
options: translateOptionLabels(config.options, tr),
|
|
301
|
+
loadOptions: config.loadOptions
|
|
302
|
+
? (q) => config.loadOptions(q).then((opts) => translateOptionLabels(opts, tr))
|
|
303
|
+
: undefined,
|
|
304
|
+
};
|
|
305
|
+
out.push({
|
|
306
|
+
key,
|
|
307
|
+
label: tr(rawLabel),
|
|
308
|
+
config: translatedConfig,
|
|
309
|
+
});
|
|
316
310
|
}
|
|
317
311
|
return out;
|
|
318
312
|
}, [metadata, columnFilterConfigs, t]);
|
|
@@ -342,7 +336,9 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
342
336
|
setLaneFilters((prev) => {
|
|
343
337
|
const merged = { ...prev[stageKey], ...patch };
|
|
344
338
|
const next = { ...prev };
|
|
345
|
-
const hasFunnel = !!(merged.field &&
|
|
339
|
+
const hasFunnel = !!(merged.field &&
|
|
340
|
+
((merged.values && merged.values.length > 0) ||
|
|
341
|
+
merged.text?.trim()));
|
|
346
342
|
const hasQuery = !!merged.query?.trim();
|
|
347
343
|
if (hasFunnel || hasQuery)
|
|
348
344
|
next[stageKey] = merged;
|
|
@@ -462,25 +458,15 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
462
458
|
})
|
|
463
459
|
: t('kanban.noActiveFilters', {
|
|
464
460
|
defaultValue: 'Sin filtros',
|
|
465
|
-
}) }), _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
|
|
466
|
-
const summary = summarizeFilterValues(field.config.selectedValues, field.config.options);
|
|
467
|
-
const dot = chipValueColor(field.config);
|
|
468
|
-
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', {
|
|
469
|
-
defaultValue: 'Quitar filtro',
|
|
470
|
-
}), children: _jsx(X, { className: "h-3 w-3" }) })] }, field.key));
|
|
471
|
-
}), _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) => {
|
|
472
462
|
const allCards = grouped.get(stage.key) ?? [];
|
|
473
463
|
// Per-lane client-side narrowing (instant, scoped to this
|
|
474
464
|
// stage). The funnel (field/value) and the lane search
|
|
475
465
|
// (query) are AND-combined.
|
|
476
466
|
const laneFilter = laneFilters[stage.key];
|
|
477
467
|
let cards = allCards;
|
|
478
|
-
if (laneFilter?.field
|
|
479
|
-
|
|
480
|
-
const field = laneFilter.field;
|
|
481
|
-
cards = cards.filter((c) => String(c[field] ?? '')
|
|
482
|
-
.toLowerCase()
|
|
483
|
-
.includes(v));
|
|
468
|
+
if (laneFilter?.field) {
|
|
469
|
+
cards = cards.filter((c) => cardMatchesLaneFunnel(c, laneFilter));
|
|
484
470
|
}
|
|
485
471
|
if (laneFilter?.query?.trim()) {
|
|
486
472
|
cards = cards.filter((c) => cardMatchesLaneQuery(c, searchCols, laneFilter.query));
|
|
@@ -490,7 +476,8 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
490
476
|
isTransitionAllowed(transitions, activeStage, stage.key);
|
|
491
477
|
return (_jsx(KanbanLane, { stage: stage, count: cards.length, totalCount: allCards.length, filterFields: filterFields, laneFilter: laneFilter, onFunnelChange: (f) => updateLaneFilter(stage.key, {
|
|
492
478
|
field: f?.field,
|
|
493
|
-
|
|
479
|
+
values: f?.values,
|
|
480
|
+
text: f?.text,
|
|
494
481
|
}), 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));
|
|
495
482
|
}) }), _jsx(DragOverlay, { children: activeCard ? (_jsx(CardPreview, { card: activeCard, titleCol: titleCol, fieldCols: fieldCols, locale: i18n.language, timeZone: timeZone, currency: currency })) : null }), rowActionDialogs] })] }));
|
|
496
483
|
}
|
|
@@ -527,11 +514,18 @@ function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onFunn
|
|
|
527
514
|
const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
|
|
528
515
|
isDark,
|
|
529
516
|
});
|
|
530
|
-
const
|
|
517
|
+
const funnelField = filterFields.find((f) => f.key === laneFilter?.field);
|
|
518
|
+
const funnelActive = !!(laneFilter?.field &&
|
|
519
|
+
((laneFilter.values && laneFilter.values.length > 0) ||
|
|
520
|
+
laneFilter.text?.trim()));
|
|
531
521
|
const queryActive = !!laneFilter?.query?.trim();
|
|
532
522
|
const laneActive = funnelActive || queryActive;
|
|
533
|
-
const activeFieldLabel =
|
|
534
|
-
|
|
523
|
+
const activeFieldLabel = funnelField?.label ?? laneFilter?.field;
|
|
524
|
+
// Human summary of the funnel value: resolved option labels for picked
|
|
525
|
+
// values, or the raw free text.
|
|
526
|
+
const funnelSummary = laneFilter?.values && laneFilter.values.length > 0
|
|
527
|
+
? summarizeFilterValues(laneFilter.values, funnelField?.config?.options)
|
|
528
|
+
: laneFilter?.text ?? '';
|
|
535
529
|
// Inline lane search: a Search icon expands an Input; Escape or blur-while-
|
|
536
530
|
// empty collapses it. The query itself lives in the parent's laneFilters so
|
|
537
531
|
// it survives collapse and combines with the funnel.
|
|
@@ -541,16 +535,20 @@ function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onFunn
|
|
|
541
535
|
if (searchOpen)
|
|
542
536
|
searchRef.current?.focus();
|
|
543
537
|
}, [searchOpen]);
|
|
544
|
-
const funnelValue = laneFilter?.field
|
|
545
|
-
? {
|
|
538
|
+
const funnelValue = laneFilter?.field
|
|
539
|
+
? {
|
|
540
|
+
field: laneFilter.field,
|
|
541
|
+
values: laneFilter.values,
|
|
542
|
+
text: laneFilter.text,
|
|
543
|
+
}
|
|
546
544
|
: undefined;
|
|
547
545
|
return (_jsxs("div", { ref: setNodeRef, className: "group/lane flex w-[300px] shrink-0 flex-col rounded-xl border bg-muted/30 transition-opacity", style: {
|
|
548
546
|
opacity: dimmed ? 0.45 : 1,
|
|
549
547
|
outline: isOver && !disabled ? '2px solid var(--ring, #3b82f6)' : 'none',
|
|
550
548
|
outlineOffset: 2,
|
|
551
|
-
}, "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:
|
|
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', {
|
|
552
550
|
defaultValue: 'Buscar en la columna',
|
|
553
|
-
}), 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) => {
|
|
554
552
|
if (e.key === 'Escape') {
|
|
555
553
|
onQueryChange('');
|
|
556
554
|
setSearchOpen(false);
|
|
@@ -560,7 +558,7 @@ function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onFunn
|
|
|
560
558
|
setSearchOpen(false);
|
|
561
559
|
}, placeholder: t('kanban.searchLanePlaceholder', {
|
|
562
560
|
defaultValue: 'Buscar tarjetas...',
|
|
563
|
-
}), 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, ": ",
|
|
561
|
+
}), 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', {
|
|
564
562
|
defaultValue: 'Limpiar',
|
|
565
563
|
}), 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 })] }));
|
|
566
564
|
}
|
|
@@ -571,53 +569,56 @@ function LaneFilterButton({ fields, value, onChange, }) {
|
|
|
571
569
|
const { t } = useTranslation();
|
|
572
570
|
const [open, setOpen] = useState(false);
|
|
573
571
|
const [field, setField] = useState(value?.field ?? fields[0]?.key ?? '');
|
|
574
|
-
const [
|
|
572
|
+
const [values, setValues] = useState(value?.values ?? []);
|
|
573
|
+
const [text, setText] = useState(value?.text ?? '');
|
|
575
574
|
// Re-seed the draft from the committed filter each time the popover opens.
|
|
576
575
|
useEffect(() => {
|
|
577
576
|
if (open) {
|
|
578
577
|
setField(value?.field ?? fields[0]?.key ?? '');
|
|
579
|
-
|
|
578
|
+
setValues(value?.values ?? []);
|
|
579
|
+
setText(value?.text ?? '');
|
|
580
580
|
}
|
|
581
581
|
}, [open, value, fields]);
|
|
582
582
|
if (fields.length === 0)
|
|
583
583
|
return null;
|
|
584
|
-
const active = !!(value &&
|
|
585
|
-
|
|
586
|
-
//
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
584
|
+
const active = !!(value &&
|
|
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);
|
|
588
|
+
// The value step mirrors the sheet: when the chosen field is a select or a
|
|
589
|
+
// facet (static options OR a lazy loader), render the SAME pro combobox —
|
|
590
|
+
// multi-select, searchable, with counts. Only a genuinely free-text field
|
|
591
|
+
// (no options, no loader) falls back to a raw "Contiene..." input.
|
|
592
|
+
const cfg = fields.find((f) => f.key === field)?.config;
|
|
593
|
+
const hasValuePicker = (cfg?.options?.length ?? 0) > 0 || !!cfg?.loadOptions;
|
|
594
|
+
const toggle = (v) => setValues((prev) => prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v]);
|
|
591
595
|
const apply = () => {
|
|
592
|
-
if (field &&
|
|
593
|
-
onChange({ field,
|
|
596
|
+
if (field && values.length > 0)
|
|
597
|
+
onChange({ field, values });
|
|
598
|
+
else if (field && text.trim())
|
|
599
|
+
onChange({ field, text: text.trim() });
|
|
594
600
|
else
|
|
595
601
|
onChange(null);
|
|
596
602
|
setOpen(false);
|
|
597
603
|
};
|
|
598
|
-
|
|
604
|
+
const clear = () => {
|
|
605
|
+
onChange(null);
|
|
606
|
+
setOpen(false);
|
|
607
|
+
};
|
|
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', {
|
|
599
609
|
defaultValue: 'Filtrar columna',
|
|
600
|
-
}), children: _jsx(ListFilter, { className: "h-3.5 w-3.5" }) }) }), _jsxs(PopoverContent, { align: "end", className: "w-
|
|
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) => {
|
|
601
611
|
setField(f);
|
|
602
612
|
// Reset the value when switching fields — a value picked
|
|
603
613
|
// for one field is meaningless for another.
|
|
614
|
+
setValues([]);
|
|
604
615
|
setText('');
|
|
605
|
-
}, 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))) })] }),
|
|
606
|
-
setText(v);
|
|
607
|
-
if (field && v)
|
|
608
|
-
onChange({ field, value: v });
|
|
609
|
-
setOpen(false);
|
|
610
|
-
}, children: [_jsx(SelectTrigger, { className: "h-8 text-xs", children: _jsx(SelectValue, { placeholder: t('kanban.filterValue', {
|
|
611
|
-
defaultValue: 'Valor...',
|
|
612
|
-
}) }) }), _jsx(SelectContent, { children: valueOptions.map((o) => (_jsx(SelectItem, { value: o.value, className: "text-xs", children: o.label }, o.value))) })] })) : (_jsx(Input, { autoFocus: true, value: text, onChange: (e) => setText(e.target.value), onKeyDown: (e) => {
|
|
616
|
+
}, 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) => {
|
|
613
617
|
if (e.key === 'Enter')
|
|
614
618
|
apply();
|
|
615
619
|
}, placeholder: t('kanban.filterValue', {
|
|
616
|
-
defaultValue: '
|
|
617
|
-
}), className: "h-8 text-xs" })), _jsxs("div", { className: "flex
|
|
618
|
-
onChange(null);
|
|
619
|
-
setOpen(false);
|
|
620
|
-
}, children: t('kanban.clearFilters', { defaultValue: 'Limpiar' }) }), _jsx(Button, { size: "sm", className: "h-7 text-xs", onClick: apply, children: t('kanban.apply', { defaultValue: 'Aplicar' }) })] })] })] }));
|
|
620
|
+
defaultValue: 'Contiene...',
|
|
621
|
+
}), 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})` : ''] })] })] })] }));
|
|
621
622
|
}
|
|
622
623
|
function KanbanCard({ card, titleCol, fieldCols, actions, locale, timeZone, currency, onClick, onAction, }) {
|
|
623
624
|
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;
|
|
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"}
|
package/dist/dynamic-table.js
CHANGED
|
@@ -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: [
|
|
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) => {
|