@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 +13 -0
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +73 -7
- package/package.json +1 -1
- package/src/__tests__/dynamic-kanban.test.tsx +10 -5
- package/src/dynamic-kanban.tsx +253 -31
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;
|
|
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"}
|
package/dist/dynamic-kanban.js
CHANGED
|
@@ -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: "
|
|
349
|
-
const
|
|
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 })] }),
|
|
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
|
@@ -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
|
|
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
|
-
//
|
|
298
|
-
//
|
|
299
|
-
|
|
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 () => {
|
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -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.
|
|
568
|
-
<
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
|
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({
|
|
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
|
-
<
|
|
701
|
-
|
|
702
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|