@asteby/metacore-runtime-react 21.0.0 → 21.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,18 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 21.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2f21e1e: feat(kanban): agrupar filtros globales en un sheet lateral + filtro por columna
8
+ - **Filtros globales** dejan de spillear como chips inline: ahora un botón "Filtros"
9
+ (con contador de activos) abre un **sheet lateral** con todos los controles apilados
10
+ (mismo motor server-side `useDynamicFilters`). La búsqueda global queda inline.
11
+ - **Filtro por lane (stage):** cada columna tiene un icono de embudo → elige un campo
12
+ - un valor y **filtra solo las cards de ese stage** (client-side, instantáneo, sin
13
+ refetch). Un indicador bajo el header muestra el filtro activo (campo: valor) con
14
+ un botón para limpiarlo; el header muestra `filtradas/total`.
15
+
3
16
  ## 21.0.0
4
17
 
5
18
  ### Minor Changes
@@ -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;AAuC9B,OAAO,KAAK,EACR,aAAa,EACb,gBAAgB,EAGhB,SAAS,EACT,eAAe,EAClB,MAAM,SAAS,CAAA;AAMhB;;;;;;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;AA8BD,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,qBAsXpB"}
1
+ {"version":3,"file":"dynamic-kanban.d.ts","sourceRoot":"","sources":["../src/dynamic-kanban.tsx"],"names":[],"mappings":"AA0BA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAoD9B,OAAO,KAAK,EACR,aAAa,EACb,gBAAgB,EAGhB,SAAS,EACT,eAAe,EAClB,MAAM,SAAS,CAAA;AAMhB;;;;;;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;AA8BD,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,qBAkcpB"}
@@ -2,9 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { useCallback, useEffect, useMemo, 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 { MoreHorizontal, Search, X } from 'lucide-react';
5
+ import { ListFilter, MoreHorizontal, Search, X } from 'lucide-react';
6
6
  import { toast } from 'sonner';
7
- import { Badge, Button, Card, CardContent, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Input, Skeleton, } from '@asteby/metacore-ui/primitives';
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 } from '@asteby/metacore-ui/data-table';
9
9
  import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib';
10
10
  import { useApi } from './api-context';
@@ -245,6 +245,23 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
245
245
  }
246
246
  return out;
247
247
  }, [metadata, columnFilterConfigs, t]);
248
+ // Sheet (grouped global filters) open state + per-lane client-side filters.
249
+ // A lane filter narrows ONLY that stage's already-fetched cards by a field
250
+ // value — instant, no refetch — so a user can drill into one column without
251
+ // touching the rest of the board (the global filters, by contrast, refetch
252
+ // the whole board server-side).
253
+ const [filtersOpen, setFiltersOpen] = useState(false);
254
+ const [laneFilters, setLaneFilters] = useState({});
255
+ const setLaneFilter = useCallback((stageKey, filter) => {
256
+ setLaneFilters((prev) => {
257
+ const next = { ...prev };
258
+ if (filter)
259
+ next[stageKey] = filter;
260
+ else
261
+ delete next[stageKey];
262
+ return next;
263
+ });
264
+ }, []);
248
265
  const stages = useMemo(() => (metadata ? deriveStages(metadata) : []), [metadata]);
249
266
  const groupByKey = metadata?.group_by || '';
250
267
  const transitions = metadata?.transitions;
@@ -345,25 +362,74 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
345
362
  order: Number.MAX_SAFE_INTEGER,
346
363
  });
347
364
  }
348
- 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.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: "h-8 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) => {
349
- const cards = grouped.get(stage.key) ?? [];
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) => {
366
+ 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.
370
+ 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;
350
376
  const droppableAllowed = !activeId ||
351
377
  stage.key === activeStage ||
352
378
  isTransitionAllowed(transitions, activeStage, stage.key);
353
- return (_jsx(KanbanLane, { stage: stage, count: cards.length, 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));
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));
354
380
  }) }), _jsx(DragOverlay, { children: activeCard ? (_jsx(CardPreview, { card: activeCard, titleCol: titleCol, fieldCols: fieldCols, locale: i18n.language, timeZone: timeZone, currency: currency })) : null }), rowActionDialogs] })] }));
355
381
  }
356
- function KanbanLane({ stage, count, isDark, dimmed, disabled, children }) {
382
+ function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onLaneFilterChange, isDark, dimmed, disabled, children, }) {
357
383
  const { t } = useTranslation();
358
384
  const { setNodeRef, isOver } = useDroppable({ id: stage.key, disabled });
359
385
  const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
360
386
  isDark,
361
387
  });
388
+ const laneActive = !!(laneFilter && laneFilter.value.trim());
389
+ const activeFieldLabel = filterFields.find((f) => f.key === laneFilter?.field)?.label ??
390
+ laneFilter?.field;
362
391
  return (_jsxs("div", { ref: setNodeRef, className: "flex w-[300px] shrink-0 flex-col rounded-lg border bg-muted/30 transition-opacity", style: {
363
392
  opacity: dimmed ? 0.45 : 1,
364
393
  outline: isOver && !disabled ? '2px solid var(--ring, #3b82f6)' : 'none',
365
394
  outlineOffset: 2,
366
- }, "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 }) }), _jsx("span", { className: "text-xs font-medium tabular-nums text-muted-foreground", children: count })] }), _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 })] }));
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', {
396
+ defaultValue: 'Limpiar',
397
+ }), 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
+ }
399
+ // LaneFilterButton — the per-column funnel. Picks a field + a value and narrows
400
+ // ONLY this lane's cards (client-side, in the parent). Draft state lives here so
401
+ // typing doesn't refilter mid-keystroke; Apply/Enter commits, Limpiar clears.
402
+ function LaneFilterButton({ fields, value, onChange, }) {
403
+ const { t } = useTranslation();
404
+ const [open, setOpen] = useState(false);
405
+ const [field, setField] = useState(value?.field ?? fields[0]?.key ?? '');
406
+ const [text, setText] = useState(value?.value ?? '');
407
+ // Re-seed the draft from the committed filter each time the popover opens.
408
+ useEffect(() => {
409
+ if (open) {
410
+ setField(value?.field ?? fields[0]?.key ?? '');
411
+ setText(value?.value ?? '');
412
+ }
413
+ }, [open, value, fields]);
414
+ if (fields.length === 0)
415
+ return null;
416
+ const active = !!(value && value.value.trim());
417
+ const apply = () => {
418
+ if (field && text.trim())
419
+ onChange({ field, value: text });
420
+ else
421
+ onChange(null);
422
+ setOpen(false);
423
+ };
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', {
425
+ 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) => {
427
+ if (e.key === 'Enter')
428
+ 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' }) })] })] })] }));
367
433
  }
368
434
  function KanbanCard({ card, titleCol, fieldCols, actions, locale, timeZone, currency, onClick, onAction, }) {
369
435
  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "21.0.0",
3
+ "version": "21.1.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -285,18 +285,23 @@ describe('optimistic drag contract', () => {
285
285
  // ---------------------------------------------------------------------------
286
286
 
287
287
  describe('DynamicKanban filter bar', () => {
288
- it('renders a search box and a chip for each filterable field', async () => {
288
+ it('renders a search box and groups filters behind a Filtros sheet', async () => {
289
289
  useMetadataCache.getState().setMetadata('issue', meta())
290
290
  render(
291
291
  <ApiProvider client={fakeApi()}>
292
292
  <DynamicKanban model="issue" />
293
293
  </ApiProvider>,
294
294
  )
295
- // search box
295
+ // search box stays inline
296
296
  expect(await screen.findByPlaceholderText('Buscar...')).toBeTruthy()
297
- // the only `filterable: true` column is `stage` a "Stage" filter chip.
298
- // (Lane headers use the STAGE labels Backlog/In Progress/… never "Stage".)
299
- expect(screen.getByText('Stage')).toBeTruthy()
297
+ // filters are grouped behind a "Filtros" button (no longer spilling as
298
+ // inline chips), so "Stage" is NOT in the DOM until the sheet opens.
299
+ const filtersBtn = screen.getByText('Filtros')
300
+ expect(filtersBtn).toBeTruthy()
301
+ expect(screen.queryByText('Stage')).toBeNull()
302
+ // opening the sheet reveals the "Stage" filter (the only filterable column)
303
+ fireEvent.click(filtersBtn)
304
+ expect(await screen.findByText('Stage')).toBeTruthy()
300
305
  })
301
306
 
302
307
  it('typing in the search box refetches the board with the search param', async () => {
@@ -38,7 +38,7 @@ import {
38
38
  type DragStartEvent,
39
39
  type DragEndEvent,
40
40
  } from '@dnd-kit/core'
41
- import { MoreHorizontal, Search, X } from 'lucide-react'
41
+ import { ListFilter, MoreHorizontal, Search, X } from 'lucide-react'
42
42
  import { toast } from 'sonner'
43
43
  import {
44
44
  Badge,
@@ -50,6 +50,19 @@ import {
50
50
  DropdownMenuItem,
51
51
  DropdownMenuTrigger,
52
52
  Input,
53
+ Popover,
54
+ PopoverContent,
55
+ PopoverTrigger,
56
+ Select,
57
+ SelectContent,
58
+ SelectItem,
59
+ SelectTrigger,
60
+ SelectValue,
61
+ Sheet,
62
+ SheetContent,
63
+ SheetHeader,
64
+ SheetTitle,
65
+ SheetTrigger,
53
66
  Skeleton,
54
67
  } from '@asteby/metacore-ui/primitives'
55
68
  import { ColumnFilterControl, type ColumnFilterType } from '@asteby/metacore-ui/data-table'
@@ -393,6 +406,27 @@ export function DynamicKanban({
393
406
  return out
394
407
  }, [metadata, columnFilterConfigs, t])
395
408
 
409
+ // Sheet (grouped global filters) open state + per-lane client-side filters.
410
+ // A lane filter narrows ONLY that stage's already-fetched cards by a field
411
+ // value — instant, no refetch — so a user can drill into one column without
412
+ // touching the rest of the board (the global filters, by contrast, refetch
413
+ // the whole board server-side).
414
+ const [filtersOpen, setFiltersOpen] = useState(false)
415
+ const [laneFilters, setLaneFilters] = useState<
416
+ Record<string, { field: string; value: string }>
417
+ >({})
418
+ const setLaneFilter = useCallback(
419
+ (stageKey: string, filter: { field: string; value: string } | null) => {
420
+ setLaneFilters((prev) => {
421
+ const next = { ...prev }
422
+ if (filter) next[stageKey] = filter
423
+ else delete next[stageKey]
424
+ return next
425
+ })
426
+ },
427
+ [],
428
+ )
429
+
396
430
  const stages = useMemo(
397
431
  () => (metadata ? deriveStages(metadata) : []),
398
432
  [metadata],
@@ -564,38 +598,87 @@ export function DynamicKanban({
564
598
  className="h-8 w-52 pl-8 text-sm"
565
599
  />
566
600
  </div>
567
- {filterFields.map((field) => (
568
- <ColumnFilterControl
569
- key={field.key}
570
- showLabel
571
- label={field.label}
572
- filterKey={field.config.filterKey}
573
- filterType={field.config.filterType as ColumnFilterType}
574
- filterOptions={field.config.options}
575
- filterLoading={field.config.loading}
576
- filterSearchEndpoint={field.config.searchEndpoint}
577
- selectedValues={field.config.selectedValues}
578
- onFilterChange={field.config.onFilterChange}
579
- />
580
- ))}
581
- {activeFilterCount > 0 && (
582
- <Button
583
- variant="ghost"
584
- size="sm"
585
- className="h-8 gap-1 text-xs text-muted-foreground"
586
- onClick={clearAll}
587
- >
588
- <X className="h-3.5 w-3.5" />
589
- {t('kanban.clearFilters', { defaultValue: 'Limpiar' })}
590
- {` (${activeFilterCount})`}
591
- </Button>
601
+ {filterFields.length > 0 && (
602
+ <Sheet open={filtersOpen} onOpenChange={setFiltersOpen}>
603
+ <SheetTrigger asChild>
604
+ <Button variant="outline" size="sm" className="h-8 gap-1.5">
605
+ <ListFilter className="h-3.5 w-3.5" />
606
+ {t('kanban.filters', { defaultValue: 'Filtros' })}
607
+ {activeFilterCount > 0 && (
608
+ <Badge
609
+ variant="secondary"
610
+ className="ml-0.5 h-4 min-w-4 justify-center rounded-full px-1 text-[10px] tabular-nums"
611
+ >
612
+ {activeFilterCount}
613
+ </Badge>
614
+ )}
615
+ </Button>
616
+ </SheetTrigger>
617
+ <SheetContent
618
+ side="right"
619
+ className="flex w-80 flex-col gap-4 sm:max-w-sm"
620
+ >
621
+ <SheetHeader className="space-y-0">
622
+ <SheetTitle>
623
+ {t('kanban.filters', { defaultValue: 'Filtros' })}
624
+ </SheetTitle>
625
+ </SheetHeader>
626
+ {/* One labeled control per filterable field, stacked. Same
627
+ configs / server-side engine as the inline chips were,
628
+ just grouped so they never spill across the board. */}
629
+ <div className="flex flex-1 flex-col gap-2 overflow-y-auto pr-1">
630
+ {filterFields.map((field) => (
631
+ <ColumnFilterControl
632
+ key={field.key}
633
+ showLabel
634
+ label={field.label}
635
+ filterKey={field.config.filterKey}
636
+ filterType={
637
+ field.config.filterType as ColumnFilterType
638
+ }
639
+ filterOptions={field.config.options}
640
+ filterLoading={field.config.loading}
641
+ filterSearchEndpoint={field.config.searchEndpoint}
642
+ selectedValues={field.config.selectedValues}
643
+ onFilterChange={field.config.onFilterChange}
644
+ />
645
+ ))}
646
+ </div>
647
+ {activeFilterCount > 0 && (
648
+ <Button
649
+ variant="ghost"
650
+ size="sm"
651
+ className="gap-1 text-xs text-muted-foreground"
652
+ onClick={clearAll}
653
+ >
654
+ <X className="h-3.5 w-3.5" />
655
+ {t('kanban.clearFilters', { defaultValue: 'Limpiar' })}
656
+ {` (${activeFilterCount})`}
657
+ </Button>
658
+ )}
659
+ </SheetContent>
660
+ </Sheet>
592
661
  )}
593
662
  </div>
594
663
 
595
664
  <DndContext sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd}>
596
665
  <div className="flex min-w-0 gap-4 overflow-x-auto p-1" data-testid="kanban-board">
597
666
  {lanes.map((stage) => {
598
- const cards = grouped.get(stage.key) ?? []
667
+ const allCards = grouped.get(stage.key) ?? []
668
+ // Per-lane client-side narrowing: substring match on the
669
+ // chosen field over the already-fetched cards. Instant, and
670
+ // scoped to this stage only.
671
+ const laneFilter = laneFilters[stage.key]
672
+ const cards =
673
+ laneFilter && laneFilter.value.trim()
674
+ ? allCards.filter((c) =>
675
+ String(c[laneFilter.field] ?? '')
676
+ .toLowerCase()
677
+ .includes(
678
+ laneFilter.value.trim().toLowerCase(),
679
+ ),
680
+ )
681
+ : allCards
599
682
  const droppableAllowed =
600
683
  !activeId ||
601
684
  stage.key === activeStage ||
@@ -605,6 +688,12 @@ export function DynamicKanban({
605
688
  key={stage.key}
606
689
  stage={stage}
607
690
  count={cards.length}
691
+ totalCount={allCards.length}
692
+ filterFields={filterFields}
693
+ laneFilter={laneFilter}
694
+ onLaneFilterChange={(f) =>
695
+ setLaneFilter(stage.key, f)
696
+ }
608
697
  isDark={isDark}
609
698
  dimmed={!!activeId && !droppableAllowed}
610
699
  disabled={!!activeId && !droppableAllowed}
@@ -665,18 +754,37 @@ export function DynamicKanban({
665
754
  interface KanbanLaneProps {
666
755
  stage: StageMeta
667
756
  count: number
757
+ totalCount: number
758
+ filterFields: { key: string; label: string }[]
759
+ laneFilter: { field: string; value: string } | undefined
760
+ onLaneFilterChange: (filter: { field: string; value: string } | null) => void
668
761
  isDark: boolean
669
762
  dimmed: boolean
670
763
  disabled: boolean
671
764
  children: React.ReactNode
672
765
  }
673
766
 
674
- function KanbanLane({ stage, count, isDark, dimmed, disabled, children }: KanbanLaneProps) {
767
+ function KanbanLane({
768
+ stage,
769
+ count,
770
+ totalCount,
771
+ filterFields,
772
+ laneFilter,
773
+ onLaneFilterChange,
774
+ isDark,
775
+ dimmed,
776
+ disabled,
777
+ children,
778
+ }: KanbanLaneProps) {
675
779
  const { t } = useTranslation()
676
780
  const { setNodeRef, isOver } = useDroppable({ id: stage.key, disabled })
677
781
  const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
678
782
  isDark,
679
783
  })
784
+ const laneActive = !!(laneFilter && laneFilter.value.trim())
785
+ const activeFieldLabel =
786
+ filterFields.find((f) => f.key === laneFilter?.field)?.label ??
787
+ laneFilter?.field
680
788
  return (
681
789
  <div
682
790
  ref={setNodeRef}
@@ -697,10 +805,35 @@ function KanbanLane({ stage, count, isDark, dimmed, disabled, children }: Kanban
697
805
  >
698
806
  {t(stage.label, { defaultValue: stage.label })}
699
807
  </Badge>
700
- <span className="text-xs font-medium tabular-nums text-muted-foreground">
701
- {count}
702
- </span>
808
+ <div className="flex items-center gap-1">
809
+ <span className="text-xs font-medium tabular-nums text-muted-foreground">
810
+ {laneActive ? `${count}/${totalCount}` : count}
811
+ </span>
812
+ <LaneFilterButton
813
+ fields={filterFields}
814
+ value={laneFilter}
815
+ onChange={onLaneFilterChange}
816
+ />
817
+ </div>
703
818
  </div>
819
+ {laneActive && (
820
+ <div className="flex items-center gap-1 px-3 pb-1.5 text-[11px] text-muted-foreground">
821
+ <ListFilter className="h-3 w-3 shrink-0" />
822
+ <span className="truncate">
823
+ {activeFieldLabel}: {laneFilter!.value}
824
+ </span>
825
+ <button
826
+ type="button"
827
+ onClick={() => onLaneFilterChange(null)}
828
+ className="ml-auto rounded p-0.5 hover:bg-muted"
829
+ aria-label={t('kanban.clearFilters', {
830
+ defaultValue: 'Limpiar',
831
+ })}
832
+ >
833
+ <X className="h-3 w-3" />
834
+ </button>
835
+ </div>
836
+ )}
704
837
  {/* Plain vertical-scroll column, NOT a Radix ScrollArea: the
705
838
  ScrollArea viewport wraps its content in a `display:table`
706
839
  element that shrink-to-fits the WIDEST card, so once the card
@@ -714,6 +847,95 @@ function KanbanLane({ stage, count, isDark, dimmed, disabled, children }: Kanban
714
847
  )
715
848
  }
716
849
 
850
+ // LaneFilterButton — the per-column funnel. Picks a field + a value and narrows
851
+ // ONLY this lane's cards (client-side, in the parent). Draft state lives here so
852
+ // typing doesn't refilter mid-keystroke; Apply/Enter commits, Limpiar clears.
853
+ function LaneFilterButton({
854
+ fields,
855
+ value,
856
+ onChange,
857
+ }: {
858
+ fields: { key: string; label: string }[]
859
+ value: { field: string; value: string } | undefined
860
+ onChange: (filter: { field: string; value: string } | null) => void
861
+ }) {
862
+ const { t } = useTranslation()
863
+ const [open, setOpen] = useState(false)
864
+ const [field, setField] = useState(value?.field ?? fields[0]?.key ?? '')
865
+ const [text, setText] = useState(value?.value ?? '')
866
+ // Re-seed the draft from the committed filter each time the popover opens.
867
+ useEffect(() => {
868
+ if (open) {
869
+ setField(value?.field ?? fields[0]?.key ?? '')
870
+ setText(value?.value ?? '')
871
+ }
872
+ }, [open, value, fields])
873
+ if (fields.length === 0) return null
874
+ const active = !!(value && value.value.trim())
875
+ const apply = () => {
876
+ if (field && text.trim()) onChange({ field, value: text })
877
+ else onChange(null)
878
+ setOpen(false)
879
+ }
880
+ return (
881
+ <Popover open={open} onOpenChange={setOpen}>
882
+ <PopoverTrigger asChild>
883
+ <button
884
+ type="button"
885
+ className={`rounded p-1 transition-colors hover:bg-muted hover:text-foreground ${
886
+ active ? 'text-primary' : 'text-muted-foreground'
887
+ }`}
888
+ aria-label={t('kanban.filterLane', {
889
+ defaultValue: 'Filtrar columna',
890
+ })}
891
+ >
892
+ <ListFilter className="h-3.5 w-3.5" />
893
+ </button>
894
+ </PopoverTrigger>
895
+ <PopoverContent align="end" className="w-56 space-y-2 p-2">
896
+ <Select value={field} onValueChange={setField}>
897
+ <SelectTrigger className="h-8 text-xs">
898
+ <SelectValue />
899
+ </SelectTrigger>
900
+ <SelectContent>
901
+ {fields.map((f) => (
902
+ <SelectItem key={f.key} value={f.key} className="text-xs">
903
+ {f.label}
904
+ </SelectItem>
905
+ ))}
906
+ </SelectContent>
907
+ </Select>
908
+ <Input
909
+ autoFocus
910
+ value={text}
911
+ onChange={(e) => setText(e.target.value)}
912
+ onKeyDown={(e) => {
913
+ if (e.key === 'Enter') apply()
914
+ }}
915
+ placeholder={t('kanban.filterValue', { defaultValue: 'Valor...' })}
916
+ className="h-8 text-xs"
917
+ />
918
+ <div className="flex justify-between gap-2">
919
+ <Button
920
+ variant="ghost"
921
+ size="sm"
922
+ className="h-7 text-xs"
923
+ onClick={() => {
924
+ onChange(null)
925
+ setOpen(false)
926
+ }}
927
+ >
928
+ {t('kanban.clearFilters', { defaultValue: 'Limpiar' })}
929
+ </Button>
930
+ <Button size="sm" className="h-7 text-xs" onClick={apply}>
931
+ {t('kanban.apply', { defaultValue: 'Aplicar' })}
932
+ </Button>
933
+ </div>
934
+ </PopoverContent>
935
+ </Popover>
936
+ )
937
+ }
938
+
717
939
  // ---------------------------------------------------------------------------
718
940
  // Card (draggable)
719
941
  // ---------------------------------------------------------------------------