@asteby/metacore-runtime-react 20.1.6 → 21.0.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,15 +11,25 @@
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
- // labels and lane labels surface verbatim.
17
+ // labels and lane labels surface verbatim. The `t`/`i18n` references are STABLE
18
+ // (module-scope singletons) — real react-i18next memoizes them, and the card's
19
+ // shared record dialog subtree keys effects off `t`/`i18n`, so returning a fresh
20
+ // object per render would spin an infinite render loop.
21
+ const I18N_T = (_k: string, opts?: { defaultValue?: string }) => opts?.defaultValue ?? _k
22
+ const I18N = { language: 'es' }
23
+ const USE_TRANSLATION = { t: I18N_T, i18n: I18N }
18
24
  vi.mock('react-i18next', () => ({
19
- useTranslation: () => ({
20
- t: (_k: string, opts?: { defaultValue?: string }) => opts?.defaultValue ?? _k,
21
- i18n: { language: 'es' },
22
- }),
25
+ useTranslation: () => USE_TRANSLATION,
26
+ }))
27
+
28
+ // The card menu's shared action handler (useDynamicRowActions) calls
29
+ // useNavigate for `link` actions; the board never navigates in these tests, so
30
+ // a stub is enough (mirrors dynamic-table-permissions.test).
31
+ vi.mock('@tanstack/react-router', () => ({
32
+ useNavigate: () => () => {},
23
33
  }))
24
34
 
25
35
  import {
@@ -266,3 +276,54 @@ describe('optimistic drag contract', () => {
266
276
  // handler returns early — no applyOptimisticMove, no PUT.
267
277
  })
268
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 a chip for each filterable field', async () => {
289
+ useMetadataCache.getState().setMetadata('issue', meta())
290
+ render(
291
+ <ApiProvider client={fakeApi()}>
292
+ <DynamicKanban model="issue" />
293
+ </ApiProvider>,
294
+ )
295
+ // search box
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()
300
+ })
301
+
302
+ it('typing in the search box refetches the board with the search param', async () => {
303
+ useMetadataCache.getState().setMetadata('issue', meta())
304
+ const get = vi.fn(async (url: string) => {
305
+ if (url.startsWith('/metadata/table/'))
306
+ return { data: { success: true, data: meta() } }
307
+ return { data: { success: true, data: CARDS } }
308
+ })
309
+ render(
310
+ <ApiProvider client={fakeApi({ get })}>
311
+ <DynamicKanban model="issue" />
312
+ </ApiProvider>,
313
+ )
314
+ const box = await screen.findByPlaceholderText('Buscar...')
315
+ fireEvent.change(box, { target: { value: 'login' } })
316
+ await waitFor(() =>
317
+ expect(get).toHaveBeenCalledWith(
318
+ '/data/issue',
319
+ expect.objectContaining({
320
+ params: expect.objectContaining({
321
+ search: 'login',
322
+ // `title` is the only `searchable: true` column in the fixture.
323
+ search_columns: 'title',
324
+ }),
325
+ }),
326
+ ),
327
+ )
328
+ })
329
+ })
@@ -0,0 +1,80 @@
1
+ // resolveRowActions / isRowActionVisible — the shared seam that makes the
2
+ // kanban card menu show the SAME, capability-gated actions as the table's row
3
+ // action column (and hide an action the user lacks permission for).
4
+ import { describe, expect, it } from 'vitest'
5
+ import { resolveRowActions, makeCan } from '../permissions-context'
6
+ import { isRowActionVisible, isActionConditionMet } from '../dynamic-columns'
7
+ import type { TableMetadata } from '../types'
8
+
9
+ const allowAll = makeCan([], true)
10
+
11
+ function meta(over: Partial<TableMetadata> = {}): TableMetadata {
12
+ return {
13
+ title: 'Issues',
14
+ endpoint: '/data/issue',
15
+ view_type: 'kanban',
16
+ group_by: 'stage',
17
+ columns: [
18
+ { key: 'title', label: 'Title', type: 'text', sortable: true, filterable: false, searchable: true },
19
+ ],
20
+ actions: [],
21
+ perPageOptions: [50],
22
+ defaultPerPage: 50,
23
+ searchPlaceholder: 'Buscar...',
24
+ enableCRUDActions: true,
25
+ hasActions: false,
26
+ ...over,
27
+ }
28
+ }
29
+
30
+ describe('resolveRowActions', () => {
31
+ it('materializes the implicit View/Edit/Delete trio for a CRUD model with no explicit actions', () => {
32
+ const actions = resolveRowActions(meta(), 'issue', allowAll, false)
33
+ expect(actions.map((a) => a.key)).toEqual(['view', 'edit', 'delete'])
34
+ })
35
+
36
+ it('returns nothing for a CRUD model once gating denies every capability', () => {
37
+ const denyAll = makeCan([], false) // no caps, not admin
38
+ const actions = resolveRowActions(meta(), 'issue', denyAll, true)
39
+ expect(actions).toEqual([])
40
+ })
41
+
42
+ it('drops only the actions the user lacks permission for (index/update granted, delete denied)', () => {
43
+ const can = makeCan(['issue.index', 'issue.update'], false)
44
+ const actions = resolveRowActions(meta(), 'issue', can, true)
45
+ // view→index, edit→update granted; delete denied → hidden
46
+ expect(actions.map((a) => a.key)).toEqual(['view', 'edit'])
47
+ })
48
+
49
+ it('prefers explicit row actions and strips table/create placements', () => {
50
+ const m = meta({
51
+ actions: [
52
+ { key: 'pagar', name: 'pagar', label: 'Pagar', placement: 'row' } as any,
53
+ { key: 'export_all', name: 'export_all', label: 'Exportar', placement: 'table' } as any,
54
+ { key: 'new_journal', name: 'new_journal', label: 'Nuevo', placement: 'create' } as any,
55
+ ],
56
+ hasActions: true,
57
+ })
58
+ const actions = resolveRowActions(m, 'issue', allowAll, false)
59
+ expect(actions.map((a) => a.key)).toEqual(['pagar'])
60
+ })
61
+ })
62
+
63
+ describe('isRowActionVisible', () => {
64
+ it('combines the requiresState gate with the declarative condition', () => {
65
+ const stateGated = { key: 'start', requiresState: ['reception'] }
66
+ expect(isRowActionVisible(stateGated, { status: 'reception' })).toBe(true)
67
+ expect(isRowActionVisible(stateGated, { status: 'in_progress' })).toBe(false)
68
+
69
+ const condGated = { key: 'refund', condition: { field: 'paid', operator: 'eq', value: 'yes' } }
70
+ expect(isRowActionVisible(condGated, { paid: 'yes' })).toBe(true)
71
+ expect(isRowActionVisible(condGated, { paid: 'no' })).toBe(false)
72
+ })
73
+
74
+ it('isActionConditionMet supports eq/neq/in/not_in and is permissive without a condition', () => {
75
+ expect(isActionConditionMet({}, { x: 1 })).toBe(true)
76
+ expect(isActionConditionMet({ condition: { field: 's', operator: 'in', value: ['a', 'b'] } }, { s: 'b' })).toBe(true)
77
+ expect(isActionConditionMet({ condition: { field: 's', operator: 'not_in', value: ['a', 'b'] } }, { s: 'c' })).toBe(true)
78
+ expect(isActionConditionMet({ condition: { field: 's', operator: 'neq', value: 'a' } }, { s: 'a' })).toBe(false)
79
+ })
80
+ })
@@ -238,6 +238,38 @@ export const isActionAllowedForRowState = (action: any, row: any): boolean => {
238
238
  return requires.map(String).includes(String(status))
239
239
  }
240
240
 
241
+ /**
242
+ * Declarative `condition` gate for a per-row action: shows the action only when
243
+ * the row's `field` satisfies the `eq | neq | in | not_in` operator. No
244
+ * condition declared → always shown. Unknown operator → permissive.
245
+ */
246
+ export const isActionConditionMet = (action: any, row: any): boolean => {
247
+ if (!action?.condition) return true
248
+ const { field, operator, value } = action.condition
249
+ const rowValue = String(row?.[field] ?? '')
250
+ const values = Array.isArray(value) ? value : [value]
251
+ switch (operator) {
252
+ case 'eq':
253
+ return rowValue === values[0]
254
+ case 'neq':
255
+ return rowValue !== values[0]
256
+ case 'in':
257
+ return values.includes(rowValue)
258
+ case 'not_in':
259
+ return !values.includes(rowValue)
260
+ default:
261
+ return true
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Whether a per-row action should appear for `row`: both the state-machine gate
267
+ * (`requiresState`) AND the declarative `condition` must pass. Shared by the
268
+ * table's action column and the kanban card menu so they hide/show identically.
269
+ */
270
+ export const isRowActionVisible = (action: any, row: any): boolean =>
271
+ isActionAllowedForRowState(action, row) && isActionConditionMet(action, row)
272
+
241
273
  const lowerFirst = (value?: string) => {
242
274
  if (!value) return value
243
275
  return value.charAt(0).toLowerCase() + value.slice(1)
@@ -1265,25 +1297,7 @@ export function makeDefaultGetDynamicColumns(
1265
1297
  </DropdownMenuTrigger>
1266
1298
  <DropdownMenuContent align="end">
1267
1299
  {resolvedActions
1268
- .filter((action) => isActionAllowedForRowState(action, row.original))
1269
- .filter((action) => {
1270
- if (!action.condition) return true
1271
- const { field, operator, value } = action.condition
1272
- const rowValue = String((row.original as any)[field] ?? '')
1273
- const values = Array.isArray(value) ? value : [value]
1274
- switch (operator) {
1275
- case 'eq':
1276
- return rowValue === values[0]
1277
- case 'neq':
1278
- return rowValue !== values[0]
1279
- case 'in':
1280
- return values.includes(rowValue)
1281
- case 'not_in':
1282
- return !values.includes(rowValue)
1283
- default:
1284
- return true
1285
- }
1286
- })
1300
+ .filter((action) => isRowActionVisible(action, row.original))
1287
1301
  .map((action) => (
1288
1302
  <DropdownMenuItem
1289
1303
  key={action.key}
@@ -11,8 +11,9 @@
11
11
  // single-value renderer that mirrors `defaultGetDynamicColumns`' display
12
12
  // logic (currency, status, date, relation chip, …) — so a card cell and a
13
13
  // table cell look identical.
14
- // - Per-card actions reuse `useModelActions(model, ['row'])` +
15
- // `ActionModalDispatcher`, the exact plumbing DynamicTable's row menu uses.
14
+ // - Per-card actions reuse `resolveRowActions` (capability-gated, same as the
15
+ // table's action column) + `useDynamicRowActions`, the EXACT shared handler
16
+ // DynamicTable's row menu dispatches through (view/edit/delete/link/custom).
16
17
  //
17
18
  // The one thing it owns that the table doesn't: an OPTIMISTIC drag-to-move.
18
19
  // Dropping a card into another lane mutates local state immediately and fires
@@ -37,7 +38,7 @@ import {
37
38
  type DragStartEvent,
38
39
  type DragEndEvent,
39
40
  } from '@dnd-kit/core'
40
- import { MoreHorizontal } from 'lucide-react'
41
+ import { MoreHorizontal, Search, X } from 'lucide-react'
41
42
  import { toast } from 'sonner'
42
43
  import {
43
44
  Badge,
@@ -48,22 +49,25 @@ import {
48
49
  DropdownMenuContent,
49
50
  DropdownMenuItem,
50
51
  DropdownMenuTrigger,
52
+ Input,
51
53
  Skeleton,
52
54
  } from '@asteby/metacore-ui/primitives'
55
+ import { ColumnFilterControl, type ColumnFilterType } from '@asteby/metacore-ui/data-table'
53
56
  import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib'
54
57
  import { useApi } from './api-context'
58
+ import { useDynamicFilters } from './use-dynamic-filters'
55
59
  import { useMetadataCache } from './metadata-cache'
56
60
  import { ActivityValueRenderer } from './activity-value-renderer'
57
- import { ActionModalDispatcher } from './action-modal-dispatcher'
58
- import { useModelActions } from './model-action-toolbar'
59
61
  import { DynamicIcon } from './dynamic-icon'
60
62
  import { isColumnVisibleInTable } from './column-visibility'
61
- import { isActionAllowedForRowState } from './dynamic-columns'
63
+ import { isRowActionVisible } from './dynamic-columns'
64
+ import { useCan, usePermissionsActive, resolveRowActions } from './permissions-context'
65
+ import { useDynamicRowActions } from './dynamic-row-actions'
62
66
  import type {
63
67
  TableMetadata,
64
68
  ColumnDefinition,
65
69
  ApiResponse,
66
- ActionMetadata,
70
+ ActionDefinition,
67
71
  StageMeta,
68
72
  StageTransition,
69
73
  } from './types'
@@ -245,6 +249,13 @@ export interface DynamicKanbanProps {
245
249
  refreshTrigger?: any
246
250
  /** Called when a card is clicked (outside its action menu). */
247
251
  onCardClick?: (row: any) => void
252
+ /**
253
+ * Host hook for `view`/`edit` card actions (STRING contract — same as
254
+ * DynamicTable's `onAction`). When provided, `view`/`edit` route to the host
255
+ * (e.g. its seeded record modal); when omitted they open the SDK's built-in
256
+ * record dialog. `delete`/link/custom actions are always handled in-SDK.
257
+ */
258
+ onAction?: (action: string, row: any) => void
248
259
  /**
249
260
  * Max cards fetched per lane render. Kanban shows all cards at once (no
250
261
  * pagination UI), so it requests a single large page. Defaults to 200.
@@ -254,6 +265,11 @@ export interface DynamicKanbanProps {
254
265
  timeZone?: string
255
266
  /** ISO 4217 currency for money card fields (org config). */
256
267
  currency?: string
268
+ /**
269
+ * Static equality filters always applied to the board (never shown as a
270
+ * removable chip). Same contract as DynamicTable's `defaultFilters`.
271
+ */
272
+ defaultFilters?: Record<string, any>
257
273
  }
258
274
 
259
275
  export function DynamicKanban({
@@ -261,9 +277,11 @@ export function DynamicKanban({
261
277
  endpoint,
262
278
  refreshTrigger,
263
279
  onCardClick,
280
+ onAction,
264
281
  pageSize = 200,
265
282
  timeZone,
266
283
  currency,
284
+ defaultFilters,
267
285
  }: DynamicKanbanProps) {
268
286
  const { t, i18n } = useTranslation()
269
287
  const api = useApi()
@@ -280,11 +298,6 @@ export function DynamicKanban({
280
298
  // Active drag card id (for the DragOverlay + drop-zone highlighting).
281
299
  const [activeId, setActiveId] = useState<string | null>(null)
282
300
 
283
- const [actionModal, setActionModal] = useState<{
284
- action: ActionMetadata | null
285
- record: any | null
286
- }>({ action: null, record: null })
287
-
288
301
  const sensors = useSensors(
289
302
  useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
290
303
  )
@@ -322,13 +335,25 @@ export function DynamicKanban({
322
335
  // eslint-disable-next-line react-hooks/exhaustive-deps
323
336
  }, [model])
324
337
 
338
+ // Shared metadata-driven filter engine — the SAME configs, option prefetch
339
+ // and `f_<key>` serialization DynamicTable uses, so the board filters
340
+ // identically to its table sibling.
341
+ const {
342
+ globalFilter,
343
+ setGlobalFilter,
344
+ columnFilterConfigs,
345
+ filterParams,
346
+ activeFilterCount,
347
+ clearAll,
348
+ } = useDynamicFilters(metadata, { defaultFilters })
349
+
325
350
  // ---- records fetch (same path as DynamicTable, single large page) ----
326
351
  const fetchData = useCallback(async () => {
327
352
  if (!metadata) return
328
353
  setLoadingData(true)
329
354
  try {
330
355
  const res = (await api.get(endpoint || `/data/${model}`, {
331
- params: { page: 1, per_page: pageSize },
356
+ params: { page: 1, per_page: pageSize, ...filterParams },
332
357
  })) as { data: ApiResponse<any[]> }
333
358
  if (res.data.success) setRecords(res.data.data || [])
334
359
  } catch (err) {
@@ -336,12 +361,37 @@ export function DynamicKanban({
336
361
  } finally {
337
362
  setLoadingData(false)
338
363
  }
339
- }, [api, endpoint, model, metadata, pageSize])
364
+ }, [api, endpoint, model, metadata, pageSize, filterParams])
340
365
 
366
+ // Refetch when metadata resolves, on an explicit refresh, or when the
367
+ // filters change. `fetchData` is stable while `filterParams` is unchanged
368
+ // (both memoized), so this only re-runs on real input changes. Debounced so
369
+ // typing in the search box doesn't fire a request per keystroke.
341
370
  useEffect(() => {
342
- if (metadata) void fetchData()
343
- // eslint-disable-next-line react-hooks/exhaustive-deps
344
- }, [metadata, refreshTrigger])
371
+ if (!metadata) return
372
+ const handle = setTimeout(() => {
373
+ void fetchData()
374
+ }, 200)
375
+ return () => clearTimeout(handle)
376
+ }, [fetchData, metadata, refreshTrigger])
377
+
378
+ // Filterable fields for the toolbar, in metadata order (explicit filters
379
+ // first, then filterable columns), each labeled from its metadata source.
380
+ const filterFields = useMemo(() => {
381
+ if (!metadata) return []
382
+ const out: {
383
+ key: string
384
+ label: string
385
+ config: NonNullable<ReturnType<typeof columnFilterConfigs.get>>
386
+ }[] = []
387
+ for (const [key, config] of columnFilterConfigs) {
388
+ const f = metadata.filters?.find((x) => x.key === key)
389
+ const c = metadata.columns.find((x) => x.key === key)
390
+ const rawLabel = f?.label || c?.label || key
391
+ out.push({ key, label: t(rawLabel, { defaultValue: rawLabel }), config })
392
+ }
393
+ return out
394
+ }, [metadata, columnFilterConfigs, t])
345
395
 
346
396
  const stages = useMemo(
347
397
  () => (metadata ? deriveStages(metadata) : []),
@@ -360,8 +410,32 @@ export function DynamicKanban({
360
410
  [metadata],
361
411
  )
362
412
 
363
- // Row-placement actions reused verbatim from the table's plumbing.
364
- const rowActions = useModelActions(model, ['row'], metadata?.actions)
413
+ // Row-placement actions resolved EXACTLY like DynamicTable's action column:
414
+ // capability-gated (when a <PermissionsProvider> is mounted) and with the
415
+ // implicit View/Edit/Delete trio materialized for CRUD models. An action the
416
+ // user lacks permission for never appears.
417
+ const can = useCan()
418
+ const permissionsActive = usePermissionsActive()
419
+ const rowActions = useMemo(
420
+ () =>
421
+ metadata
422
+ ? resolveRowActions(metadata, model, can, permissionsActive, (k, fb) =>
423
+ t(k, { defaultValue: fb }),
424
+ )
425
+ : [],
426
+ [metadata, model, can, permissionsActive, t],
427
+ )
428
+
429
+ // Shared row-action dispatch + dialogs — view/edit/delete/link/custom behave
430
+ // identically to a table row (the card menu used to forward the raw action
431
+ // object to the host and silently no-op).
432
+ const { handleInternalAction, dialogs: rowActionDialogs } = useDynamicRowActions({
433
+ model,
434
+ endpoint,
435
+ metadata,
436
+ onAction,
437
+ onRefresh: fetchData,
438
+ })
365
439
 
366
440
  const cardById = useMemo(() => {
367
441
  const m = new Map<string, any>()
@@ -475,8 +549,51 @@ export function DynamicKanban({
475
549
  }
476
550
 
477
551
  return (
478
- <DndContext sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd}>
479
- <div className="flex min-w-0 gap-4 overflow-x-auto p-1" data-testid="kanban-board">
552
+ <div className="flex flex-col gap-3">
553
+ {/* Filter bar global search + one chip per filterable field, the
554
+ SAME set the DynamicTable exposes in its column headers. Changing
555
+ any control refetches the board server-side (debounced) via the
556
+ shared useDynamicFilters engine. */}
557
+ <div className="flex flex-wrap items-center gap-2" data-testid="kanban-filters">
558
+ <div className="relative">
559
+ <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" />
560
+ <Input
561
+ value={globalFilter}
562
+ onChange={(e) => setGlobalFilter(e.target.value)}
563
+ placeholder={t('kanban.searchPlaceholder', { defaultValue: 'Buscar...' })}
564
+ className="h-8 w-52 pl-8 text-sm"
565
+ />
566
+ </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>
592
+ )}
593
+ </div>
594
+
595
+ <DndContext sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd}>
596
+ <div className="flex min-w-0 gap-4 overflow-x-auto p-1" data-testid="kanban-board">
480
597
  {lanes.map((stage) => {
481
598
  const cards = grouped.get(stage.key) ?? []
482
599
  const droppableAllowed =
@@ -513,9 +630,7 @@ export function DynamicKanban({
513
630
  timeZone={timeZone}
514
631
  currency={currency}
515
632
  onClick={onCardClick}
516
- onAction={(action, record) =>
517
- setActionModal({ action, record })
518
- }
633
+ onAction={handleInternalAction}
519
634
  />
520
635
  ))
521
636
  )}
@@ -537,23 +652,9 @@ export function DynamicKanban({
537
652
  ) : null}
538
653
  </DragOverlay>
539
654
 
540
- {actionModal.action && (
541
- <ActionModalDispatcher
542
- open={!!actionModal.action}
543
- onOpenChange={(open) => {
544
- if (!open) setActionModal({ action: null, record: null })
545
- }}
546
- action={actionModal.action}
547
- model={model}
548
- record={actionModal.record ?? {}}
549
- endpoint={endpoint ?? `/data/${model}/me`}
550
- onSuccess={() => {
551
- setActionModal({ action: null, record: null })
552
- void fetchData()
553
- }}
554
- />
555
- )}
556
- </DndContext>
655
+ {rowActionDialogs}
656
+ </DndContext>
657
+ </div>
557
658
  )
558
659
  }
559
660
 
@@ -621,12 +722,13 @@ interface KanbanCardProps {
621
722
  card: any
622
723
  titleCol: ColumnDefinition | null
623
724
  fieldCols: ColumnDefinition[]
624
- actions: ActionMetadata[] | any[]
725
+ actions: ActionDefinition[]
625
726
  locale: string
626
727
  timeZone?: string
627
728
  currency?: string
628
729
  onClick?: (row: any) => void
629
- onAction: (action: ActionMetadata, record: any) => void
730
+ /** STRING contract — dispatch the action by its key (see useDynamicRowActions). */
731
+ onAction: (actionKey: string, record: any) => void
630
732
  }
631
733
 
632
734
  function KanbanCard({
@@ -644,9 +746,7 @@ function KanbanCard({
644
746
  id: String(card.id),
645
747
  })
646
748
 
647
- const visibleActions = (actions as any[]).filter((a) =>
648
- isActionAllowedForRowState(a, card),
649
- )
749
+ const visibleActions = actions.filter((a) => isRowActionVisible(a, card))
650
750
 
651
751
  return (
652
752
  <Card
@@ -693,7 +793,7 @@ function KanbanCard({
693
793
  key={a.key}
694
794
  onClick={(e) => {
695
795
  e.stopPropagation()
696
- onAction(a as ActionMetadata, card)
796
+ onAction(a.key, card)
697
797
  }}
698
798
  >
699
799
  <DynamicIcon