@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.
@@ -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
+ })
@@ -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) void fetchData()
347
- // eslint-disable-next-line react-hooks/exhaustive-deps
348
- }, [metadata, refreshTrigger])
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
- <DndContext sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd}>
507
- <div className="flex min-w-0 gap-4 overflow-x-auto p-1" data-testid="kanban-board">
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 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
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
- </DndContext>
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({ 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) {
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
- <span className="text-xs font-medium tabular-nums text-muted-foreground">
611
- {count}
612
- </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>
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,