@asteby/metacore-runtime-react 20.1.7 → 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 +38 -0
- package/dist/dynamic-kanban.d.ts +6 -1
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +110 -17
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/use-dynamic-filters.d.ts +38 -0
- package/dist/use-dynamic-filters.d.ts.map +1 -0
- package/dist/use-dynamic-filters.js +226 -0
- package/package.json +3 -3
- package/src/__tests__/dynamic-kanban.test.tsx +57 -1
- package/src/dynamic-kanban.tsx +326 -14
- package/src/index.ts +5 -0
- package/src/use-dynamic-filters.ts +272 -0
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// and the destination payload is `{ <group_by>: <dest> }`; an invalid
|
|
12
12
|
// transition is rejected before any PUT.
|
|
13
13
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
14
|
-
import { cleanup, render, screen } from '@testing-library/react'
|
|
14
|
+
import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
15
15
|
|
|
16
16
|
// react-i18next: identity translator returning the defaultValue so card field
|
|
17
17
|
// labels and lane labels surface verbatim. The `t`/`i18n` references are STABLE
|
|
@@ -276,3 +276,59 @@ describe('optimistic drag contract', () => {
|
|
|
276
276
|
// handler returns early — no applyOptimisticMove, no PUT.
|
|
277
277
|
})
|
|
278
278
|
})
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// 4. Filter bar — the DynamicKanban parity with DynamicTable column filters.
|
|
282
|
+
// The bar renders a search box + one chip per filterable field, and any
|
|
283
|
+
// control refetches the board server-side through useDynamicFilters
|
|
284
|
+
// (debounced), carrying the same `f_<key>` / `search` params.
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
describe('DynamicKanban filter bar', () => {
|
|
288
|
+
it('renders a search box and groups filters behind a Filtros sheet', async () => {
|
|
289
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
290
|
+
render(
|
|
291
|
+
<ApiProvider client={fakeApi()}>
|
|
292
|
+
<DynamicKanban model="issue" />
|
|
293
|
+
</ApiProvider>,
|
|
294
|
+
)
|
|
295
|
+
// search box stays inline
|
|
296
|
+
expect(await screen.findByPlaceholderText('Buscar...')).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()
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('typing in the search box refetches the board with the search param', async () => {
|
|
308
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
309
|
+
const get = vi.fn(async (url: string) => {
|
|
310
|
+
if (url.startsWith('/metadata/table/'))
|
|
311
|
+
return { data: { success: true, data: meta() } }
|
|
312
|
+
return { data: { success: true, data: CARDS } }
|
|
313
|
+
})
|
|
314
|
+
render(
|
|
315
|
+
<ApiProvider client={fakeApi({ get })}>
|
|
316
|
+
<DynamicKanban model="issue" />
|
|
317
|
+
</ApiProvider>,
|
|
318
|
+
)
|
|
319
|
+
const box = await screen.findByPlaceholderText('Buscar...')
|
|
320
|
+
fireEvent.change(box, { target: { value: 'login' } })
|
|
321
|
+
await waitFor(() =>
|
|
322
|
+
expect(get).toHaveBeenCalledWith(
|
|
323
|
+
'/data/issue',
|
|
324
|
+
expect.objectContaining({
|
|
325
|
+
params: expect.objectContaining({
|
|
326
|
+
search: 'login',
|
|
327
|
+
// `title` is the only `searchable: true` column in the fixture.
|
|
328
|
+
search_columns: 'title',
|
|
329
|
+
}),
|
|
330
|
+
}),
|
|
331
|
+
),
|
|
332
|
+
)
|
|
333
|
+
})
|
|
334
|
+
})
|
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 } from 'lucide-react'
|
|
41
|
+
import { ListFilter, MoreHorizontal, Search, X } from 'lucide-react'
|
|
42
42
|
import { toast } from 'sonner'
|
|
43
43
|
import {
|
|
44
44
|
Badge,
|
|
@@ -49,10 +49,26 @@ import {
|
|
|
49
49
|
DropdownMenuContent,
|
|
50
50
|
DropdownMenuItem,
|
|
51
51
|
DropdownMenuTrigger,
|
|
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,
|
|
52
66
|
Skeleton,
|
|
53
67
|
} from '@asteby/metacore-ui/primitives'
|
|
68
|
+
import { ColumnFilterControl, type ColumnFilterType } from '@asteby/metacore-ui/data-table'
|
|
54
69
|
import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib'
|
|
55
70
|
import { useApi } from './api-context'
|
|
71
|
+
import { useDynamicFilters } from './use-dynamic-filters'
|
|
56
72
|
import { useMetadataCache } from './metadata-cache'
|
|
57
73
|
import { ActivityValueRenderer } from './activity-value-renderer'
|
|
58
74
|
import { DynamicIcon } from './dynamic-icon'
|
|
@@ -262,6 +278,11 @@ export interface DynamicKanbanProps {
|
|
|
262
278
|
timeZone?: string
|
|
263
279
|
/** ISO 4217 currency for money card fields (org config). */
|
|
264
280
|
currency?: string
|
|
281
|
+
/**
|
|
282
|
+
* Static equality filters always applied to the board (never shown as a
|
|
283
|
+
* removable chip). Same contract as DynamicTable's `defaultFilters`.
|
|
284
|
+
*/
|
|
285
|
+
defaultFilters?: Record<string, any>
|
|
265
286
|
}
|
|
266
287
|
|
|
267
288
|
export function DynamicKanban({
|
|
@@ -273,6 +294,7 @@ export function DynamicKanban({
|
|
|
273
294
|
pageSize = 200,
|
|
274
295
|
timeZone,
|
|
275
296
|
currency,
|
|
297
|
+
defaultFilters,
|
|
276
298
|
}: DynamicKanbanProps) {
|
|
277
299
|
const { t, i18n } = useTranslation()
|
|
278
300
|
const api = useApi()
|
|
@@ -326,13 +348,25 @@ export function DynamicKanban({
|
|
|
326
348
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
327
349
|
}, [model])
|
|
328
350
|
|
|
351
|
+
// Shared metadata-driven filter engine — the SAME configs, option prefetch
|
|
352
|
+
// and `f_<key>` serialization DynamicTable uses, so the board filters
|
|
353
|
+
// identically to its table sibling.
|
|
354
|
+
const {
|
|
355
|
+
globalFilter,
|
|
356
|
+
setGlobalFilter,
|
|
357
|
+
columnFilterConfigs,
|
|
358
|
+
filterParams,
|
|
359
|
+
activeFilterCount,
|
|
360
|
+
clearAll,
|
|
361
|
+
} = useDynamicFilters(metadata, { defaultFilters })
|
|
362
|
+
|
|
329
363
|
// ---- records fetch (same path as DynamicTable, single large page) ----
|
|
330
364
|
const fetchData = useCallback(async () => {
|
|
331
365
|
if (!metadata) return
|
|
332
366
|
setLoadingData(true)
|
|
333
367
|
try {
|
|
334
368
|
const res = (await api.get(endpoint || `/data/${model}`, {
|
|
335
|
-
params: { page: 1, per_page: pageSize },
|
|
369
|
+
params: { page: 1, per_page: pageSize, ...filterParams },
|
|
336
370
|
})) as { data: ApiResponse<any[]> }
|
|
337
371
|
if (res.data.success) setRecords(res.data.data || [])
|
|
338
372
|
} catch (err) {
|
|
@@ -340,12 +374,58 @@ export function DynamicKanban({
|
|
|
340
374
|
} finally {
|
|
341
375
|
setLoadingData(false)
|
|
342
376
|
}
|
|
343
|
-
}, [api, endpoint, model, metadata, pageSize])
|
|
377
|
+
}, [api, endpoint, model, metadata, pageSize, filterParams])
|
|
344
378
|
|
|
379
|
+
// Refetch when metadata resolves, on an explicit refresh, or when the
|
|
380
|
+
// filters change. `fetchData` is stable while `filterParams` is unchanged
|
|
381
|
+
// (both memoized), so this only re-runs on real input changes. Debounced so
|
|
382
|
+
// typing in the search box doesn't fire a request per keystroke.
|
|
345
383
|
useEffect(() => {
|
|
346
|
-
if (metadata)
|
|
347
|
-
|
|
348
|
-
|
|
384
|
+
if (!metadata) return
|
|
385
|
+
const handle = setTimeout(() => {
|
|
386
|
+
void fetchData()
|
|
387
|
+
}, 200)
|
|
388
|
+
return () => clearTimeout(handle)
|
|
389
|
+
}, [fetchData, metadata, refreshTrigger])
|
|
390
|
+
|
|
391
|
+
// Filterable fields for the toolbar, in metadata order (explicit filters
|
|
392
|
+
// first, then filterable columns), each labeled from its metadata source.
|
|
393
|
+
const filterFields = useMemo(() => {
|
|
394
|
+
if (!metadata) return []
|
|
395
|
+
const out: {
|
|
396
|
+
key: string
|
|
397
|
+
label: string
|
|
398
|
+
config: NonNullable<ReturnType<typeof columnFilterConfigs.get>>
|
|
399
|
+
}[] = []
|
|
400
|
+
for (const [key, config] of columnFilterConfigs) {
|
|
401
|
+
const f = metadata.filters?.find((x) => x.key === key)
|
|
402
|
+
const c = metadata.columns.find((x) => x.key === key)
|
|
403
|
+
const rawLabel = f?.label || c?.label || key
|
|
404
|
+
out.push({ key, label: t(rawLabel, { defaultValue: rawLabel }), config })
|
|
405
|
+
}
|
|
406
|
+
return out
|
|
407
|
+
}, [metadata, columnFilterConfigs, t])
|
|
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
|
+
)
|
|
349
429
|
|
|
350
430
|
const stages = useMemo(
|
|
351
431
|
() => (metadata ? deriveStages(metadata) : []),
|
|
@@ -503,10 +583,102 @@ export function DynamicKanban({
|
|
|
503
583
|
}
|
|
504
584
|
|
|
505
585
|
return (
|
|
506
|
-
<
|
|
507
|
-
|
|
586
|
+
<div className="flex flex-col gap-3">
|
|
587
|
+
{/* Filter bar — global search + one chip per filterable field, the
|
|
588
|
+
SAME set the DynamicTable exposes in its column headers. Changing
|
|
589
|
+
any control refetches the board server-side (debounced) via the
|
|
590
|
+
shared useDynamicFilters engine. */}
|
|
591
|
+
<div className="flex flex-wrap items-center gap-2" data-testid="kanban-filters">
|
|
592
|
+
<div className="relative">
|
|
593
|
+
<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" />
|
|
594
|
+
<Input
|
|
595
|
+
value={globalFilter}
|
|
596
|
+
onChange={(e) => setGlobalFilter(e.target.value)}
|
|
597
|
+
placeholder={t('kanban.searchPlaceholder', { defaultValue: 'Buscar...' })}
|
|
598
|
+
className="h-8 w-52 pl-8 text-sm"
|
|
599
|
+
/>
|
|
600
|
+
</div>
|
|
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>
|
|
661
|
+
)}
|
|
662
|
+
</div>
|
|
663
|
+
|
|
664
|
+
<DndContext sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
|
665
|
+
<div className="flex min-w-0 gap-4 overflow-x-auto p-1" data-testid="kanban-board">
|
|
508
666
|
{lanes.map((stage) => {
|
|
509
|
-
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
|
|
510
682
|
const droppableAllowed =
|
|
511
683
|
!activeId ||
|
|
512
684
|
stage.key === activeStage ||
|
|
@@ -516,6 +688,12 @@ export function DynamicKanban({
|
|
|
516
688
|
key={stage.key}
|
|
517
689
|
stage={stage}
|
|
518
690
|
count={cards.length}
|
|
691
|
+
totalCount={allCards.length}
|
|
692
|
+
filterFields={filterFields}
|
|
693
|
+
laneFilter={laneFilter}
|
|
694
|
+
onLaneFilterChange={(f) =>
|
|
695
|
+
setLaneFilter(stage.key, f)
|
|
696
|
+
}
|
|
519
697
|
isDark={isDark}
|
|
520
698
|
dimmed={!!activeId && !droppableAllowed}
|
|
521
699
|
disabled={!!activeId && !droppableAllowed}
|
|
@@ -564,7 +742,8 @@ export function DynamicKanban({
|
|
|
564
742
|
</DragOverlay>
|
|
565
743
|
|
|
566
744
|
{rowActionDialogs}
|
|
567
|
-
|
|
745
|
+
</DndContext>
|
|
746
|
+
</div>
|
|
568
747
|
)
|
|
569
748
|
}
|
|
570
749
|
|
|
@@ -575,18 +754,37 @@ export function DynamicKanban({
|
|
|
575
754
|
interface KanbanLaneProps {
|
|
576
755
|
stage: StageMeta
|
|
577
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
|
|
578
761
|
isDark: boolean
|
|
579
762
|
dimmed: boolean
|
|
580
763
|
disabled: boolean
|
|
581
764
|
children: React.ReactNode
|
|
582
765
|
}
|
|
583
766
|
|
|
584
|
-
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) {
|
|
585
779
|
const { t } = useTranslation()
|
|
586
780
|
const { setNodeRef, isOver } = useDroppable({ id: stage.key, disabled })
|
|
587
781
|
const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
|
|
588
782
|
isDark,
|
|
589
783
|
})
|
|
784
|
+
const laneActive = !!(laneFilter && laneFilter.value.trim())
|
|
785
|
+
const activeFieldLabel =
|
|
786
|
+
filterFields.find((f) => f.key === laneFilter?.field)?.label ??
|
|
787
|
+
laneFilter?.field
|
|
590
788
|
return (
|
|
591
789
|
<div
|
|
592
790
|
ref={setNodeRef}
|
|
@@ -607,10 +805,35 @@ function KanbanLane({ stage, count, isDark, dimmed, disabled, children }: Kanban
|
|
|
607
805
|
>
|
|
608
806
|
{t(stage.label, { defaultValue: stage.label })}
|
|
609
807
|
</Badge>
|
|
610
|
-
<
|
|
611
|
-
|
|
612
|
-
|
|
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>
|
|
613
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
|
+
)}
|
|
614
837
|
{/* Plain vertical-scroll column, NOT a Radix ScrollArea: the
|
|
615
838
|
ScrollArea viewport wraps its content in a `display:table`
|
|
616
839
|
element that shrink-to-fits the WIDEST card, so once the card
|
|
@@ -624,6 +847,95 @@ function KanbanLane({ stage, count, isDark, dimmed, disabled, children }: Kanban
|
|
|
624
847
|
)
|
|
625
848
|
}
|
|
626
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
|
+
|
|
627
939
|
// ---------------------------------------------------------------------------
|
|
628
940
|
// Card (draggable)
|
|
629
941
|
// ---------------------------------------------------------------------------
|
package/src/index.ts
CHANGED
|
@@ -23,6 +23,11 @@ export {
|
|
|
23
23
|
resolveActiveView,
|
|
24
24
|
type DynamicViewProps,
|
|
25
25
|
} from './dynamic-view'
|
|
26
|
+
export {
|
|
27
|
+
useDynamicFilters,
|
|
28
|
+
type UseDynamicFiltersOptions,
|
|
29
|
+
type UseDynamicFiltersResult,
|
|
30
|
+
} from './use-dynamic-filters'
|
|
26
31
|
export * from './dynamic-form'
|
|
27
32
|
export {
|
|
28
33
|
ActionModalDispatcher,
|