@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.
- package/CHANGELOG.md +43 -0
- package/dist/dynamic-columns.d.ts +12 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +31 -20
- package/dist/dynamic-kanban.d.ts +13 -1
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +66 -29
- package/dist/dynamic-row-actions.d.ts +36 -0
- package/dist/dynamic-row-actions.d.ts.map +1 -0
- package/dist/dynamic-row-actions.js +110 -0
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +11 -76
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/permissions-context.d.ts +15 -1
- package/dist/permissions-context.d.ts.map +1 -1
- package/dist/permissions-context.js +29 -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 +67 -6
- package/src/__tests__/resolve-row-actions.test.ts +80 -0
- package/src/dynamic-columns.tsx +33 -19
- package/src/dynamic-kanban.tsx +147 -47
- package/src/dynamic-row-actions.tsx +195 -0
- package/src/dynamic-table.tsx +12 -107
- package/src/index.ts +11 -0
- package/src/permissions-context.tsx +39 -0
- package/src/use-dynamic-filters.ts +272 -0
|
@@ -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
|
-
|
|
21
|
-
|
|
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
|
+
})
|
package/src/dynamic-columns.tsx
CHANGED
|
@@ -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) =>
|
|
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}
|
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -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 `
|
|
15
|
-
// `
|
|
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 {
|
|
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
|
-
|
|
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)
|
|
343
|
-
|
|
344
|
-
|
|
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
|
|
364
|
-
|
|
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
|
-
<
|
|
479
|
-
|
|
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={
|
|
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
|
-
{
|
|
541
|
-
|
|
542
|
-
|
|
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:
|
|
725
|
+
actions: ActionDefinition[]
|
|
625
726
|
locale: string
|
|
626
727
|
timeZone?: string
|
|
627
728
|
currency?: string
|
|
628
729
|
onClick?: (row: any) => void
|
|
629
|
-
|
|
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 =
|
|
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
|
|
796
|
+
onAction(a.key, card)
|
|
697
797
|
}}
|
|
698
798
|
>
|
|
699
799
|
<DynamicIcon
|