@asteby/metacore-runtime-react 20.1.5 → 20.1.7
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 +31 -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 +8 -1
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +30 -20
- 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 +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -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/package.json +1 -1
- package/src/__tests__/dynamic-kanban.test.tsx +15 -5
- package/src/__tests__/resolve-row-actions.test.ts +80 -0
- package/src/dynamic-columns.tsx +33 -19
- package/src/dynamic-kanban.tsx +51 -41
- package/src/dynamic-row-actions.tsx +195 -0
- package/src/dynamic-table.tsx +12 -107
- package/src/index.ts +6 -0
- package/src/permissions-context.tsx +39 -0
package/dist/index.js
CHANGED
|
@@ -15,7 +15,8 @@ export * from './addon-loader';
|
|
|
15
15
|
export { AddonLayoutProvider, useAddonLayout, useAddonLayoutControl, useDeclareAddonLayout, } from './addon-layout-context';
|
|
16
16
|
export * from './slot';
|
|
17
17
|
export * from './capability-gate';
|
|
18
|
-
export { PermissionsProvider, useCan, usePermissionsActive, makeCan, capabilityForActionKey, modelCapability, gateTableMetadata, } from './permissions-context';
|
|
18
|
+
export { PermissionsProvider, useCan, usePermissionsActive, makeCan, capabilityForActionKey, modelCapability, gateTableMetadata, resolveRowActions, } from './permissions-context';
|
|
19
|
+
export { useDynamicRowActions, } from './dynamic-row-actions';
|
|
19
20
|
export { PermissionsManager, moduleActionCapability, moduleCapabilities, grantedCountForModule, capabilitySetsEqual, defaultActionIcon, normalizeCatalogGroups, flattenGroups, filterModuleGroups, } from './permissions-manager';
|
|
20
21
|
export * from './org-runtime-context';
|
|
21
22
|
export * from './org-runtime-provider';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import type { TableMetadata } from './types';
|
|
2
|
+
import type { TableMetadata, ActionDefinition } from './types';
|
|
3
3
|
/** Predicate answering "can the current user use this capability?". */
|
|
4
4
|
export type CanFn = (capability: string) => boolean;
|
|
5
5
|
export interface PermissionsProviderProps {
|
|
@@ -45,4 +45,18 @@ export declare function modelCapability(model: string, actionKey: string): strin
|
|
|
45
45
|
* (`usePermissionsActive()`), otherwise pass the metadata through untouched.
|
|
46
46
|
*/
|
|
47
47
|
export declare function gateTableMetadata(metadata: TableMetadata, model: string, can: CanFn, tx?: (i18nKey: string, fallback: string) => string): TableMetadata;
|
|
48
|
+
/**
|
|
49
|
+
* Resolves the per-row (`placement: 'row'`) action list for a model exactly the
|
|
50
|
+
* way DynamicTable's action column does, so the kanban card menu shows the SAME
|
|
51
|
+
* actions:
|
|
52
|
+
* - when a <PermissionsProvider> is active, the metadata is run through
|
|
53
|
+
* `gateTableMetadata` first (filters by capability + materializes the CRUD
|
|
54
|
+
* trio), otherwise the raw metadata is used.
|
|
55
|
+
* - explicit actions win; absent them, the implicit View/Edit/Delete trio is
|
|
56
|
+
* materialized when `enableCRUDActions` is on.
|
|
57
|
+
* - table/create-placement actions are stripped (they belong to the toolbar).
|
|
58
|
+
*
|
|
59
|
+
* Pure. `tx` resolves the trio's i18n labels (defaults to the Spanish fallbacks).
|
|
60
|
+
*/
|
|
61
|
+
export declare function resolveRowActions(metadata: TableMetadata, model: string, can: CanFn, permissionsActive: boolean, tx?: (i18nKey: string, fallback: string) => string): ActionDefinition[];
|
|
48
62
|
//# sourceMappingURL=permissions-context.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"permissions-context.d.ts","sourceRoot":"","sources":["../src/permissions-context.tsx"],"names":[],"mappings":"AAsBA,OAAO,KAA6C,MAAM,OAAO,CAAA;AACjE,OAAO,KAAK,EAAE,aAAa,
|
|
1
|
+
{"version":3,"file":"permissions-context.d.ts","sourceRoot":"","sources":["../src/permissions-context.tsx"],"names":[],"mappings":"AAsBA,OAAO,KAA6C,MAAM,OAAO,CAAA;AACjE,OAAO,KAAK,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAM9D,uEAAuE;AACvE,MAAM,MAAM,KAAK,GAAG,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAA;AAEnD,MAAM,WAAW,wBAAwB;IACrC,6EAA6E;IAC7E,WAAW,EAAE,MAAM,EAAE,CAAA;IACrB,4EAA4E;IAC5E,OAAO,EAAE,OAAO,CAAA;IAChB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CAC5B;AAMD;;;GAGG;AACH,wBAAgB,OAAO,CAAC,WAAW,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,OAAO,GAAG,KAAK,CAKtE;AAMD,wBAAgB,mBAAmB,CAAC,EAAE,WAAW,EAAE,OAAO,EAAE,QAAQ,EAAE,EAAE,wBAAwB,qBAG/F;AAED;;;;GAIG;AACH,wBAAgB,MAAM,IAAI,KAAK,CAE9B;AAED,qFAAqF;AACrF,wBAAgB,oBAAoB,IAAI,OAAO,CAE9C;AAOD;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAIhE;AAED,kFAAkF;AAClF,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAExE;AAQD;;;;;;;;;;;;GAYG;AACH,wBAAgB,iBAAiB,CAC7B,QAAQ,EAAE,aAAa,EACvB,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,KAAK,EACV,EAAE,GAAE,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,MAAmC,GAC/E,aAAa,CAkCf;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,iBAAiB,CAC7B,QAAQ,EAAE,aAAa,EACvB,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,KAAK,EACV,iBAAiB,EAAE,OAAO,EAC1B,EAAE,GAAE,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,MAAmC,GAC/E,gBAAgB,EAAE,CAkBpB"}
|
|
@@ -122,3 +122,32 @@ export function gateTableMetadata(metadata, model, can, tx = (_k, fallback) => f
|
|
|
122
122
|
canCreate: metadata.canCreate === undefined ? undefined : metadata.canCreate && allowed('create'),
|
|
123
123
|
};
|
|
124
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Resolves the per-row (`placement: 'row'`) action list for a model exactly the
|
|
127
|
+
* way DynamicTable's action column does, so the kanban card menu shows the SAME
|
|
128
|
+
* actions:
|
|
129
|
+
* - when a <PermissionsProvider> is active, the metadata is run through
|
|
130
|
+
* `gateTableMetadata` first (filters by capability + materializes the CRUD
|
|
131
|
+
* trio), otherwise the raw metadata is used.
|
|
132
|
+
* - explicit actions win; absent them, the implicit View/Edit/Delete trio is
|
|
133
|
+
* materialized when `enableCRUDActions` is on.
|
|
134
|
+
* - table/create-placement actions are stripped (they belong to the toolbar).
|
|
135
|
+
*
|
|
136
|
+
* Pure. `tx` resolves the trio's i18n labels (defaults to the Spanish fallbacks).
|
|
137
|
+
*/
|
|
138
|
+
export function resolveRowActions(metadata, model, can, permissionsActive, tx = (_k, fallback) => fallback) {
|
|
139
|
+
const gated = permissionsActive ? gateTableMetadata(metadata, model, can, tx) : metadata;
|
|
140
|
+
const explicit = gated.actions ?? [];
|
|
141
|
+
const hasExplicit = (gated.hasActions ?? explicit.length > 0) && explicit.length > 0;
|
|
142
|
+
const base = hasExplicit
|
|
143
|
+
? explicit
|
|
144
|
+
: gated.enableCRUDActions
|
|
145
|
+
? DEFAULT_TRIO.map((a) => ({
|
|
146
|
+
key: a.key,
|
|
147
|
+
name: a.key,
|
|
148
|
+
label: tx(a.i18nKey, a.fallback),
|
|
149
|
+
icon: a.icon,
|
|
150
|
+
}))
|
|
151
|
+
: [];
|
|
152
|
+
return base.filter((a) => (a.placement ?? 'row') === 'row');
|
|
153
|
+
}
|
package/package.json
CHANGED
|
@@ -14,12 +14,22 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
14
14
|
import { cleanup, render, screen } 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 {
|
|
@@ -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
|
|
@@ -54,16 +55,16 @@ import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib'
|
|
|
54
55
|
import { useApi } from './api-context'
|
|
55
56
|
import { useMetadataCache } from './metadata-cache'
|
|
56
57
|
import { ActivityValueRenderer } from './activity-value-renderer'
|
|
57
|
-
import { ActionModalDispatcher } from './action-modal-dispatcher'
|
|
58
|
-
import { useModelActions } from './model-action-toolbar'
|
|
59
58
|
import { DynamicIcon } from './dynamic-icon'
|
|
60
59
|
import { isColumnVisibleInTable } from './column-visibility'
|
|
61
|
-
import {
|
|
60
|
+
import { isRowActionVisible } from './dynamic-columns'
|
|
61
|
+
import { useCan, usePermissionsActive, resolveRowActions } from './permissions-context'
|
|
62
|
+
import { useDynamicRowActions } from './dynamic-row-actions'
|
|
62
63
|
import type {
|
|
63
64
|
TableMetadata,
|
|
64
65
|
ColumnDefinition,
|
|
65
66
|
ApiResponse,
|
|
66
|
-
|
|
67
|
+
ActionDefinition,
|
|
67
68
|
StageMeta,
|
|
68
69
|
StageTransition,
|
|
69
70
|
} from './types'
|
|
@@ -245,6 +246,13 @@ export interface DynamicKanbanProps {
|
|
|
245
246
|
refreshTrigger?: any
|
|
246
247
|
/** Called when a card is clicked (outside its action menu). */
|
|
247
248
|
onCardClick?: (row: any) => void
|
|
249
|
+
/**
|
|
250
|
+
* Host hook for `view`/`edit` card actions (STRING contract — same as
|
|
251
|
+
* DynamicTable's `onAction`). When provided, `view`/`edit` route to the host
|
|
252
|
+
* (e.g. its seeded record modal); when omitted they open the SDK's built-in
|
|
253
|
+
* record dialog. `delete`/link/custom actions are always handled in-SDK.
|
|
254
|
+
*/
|
|
255
|
+
onAction?: (action: string, row: any) => void
|
|
248
256
|
/**
|
|
249
257
|
* Max cards fetched per lane render. Kanban shows all cards at once (no
|
|
250
258
|
* pagination UI), so it requests a single large page. Defaults to 200.
|
|
@@ -261,6 +269,7 @@ export function DynamicKanban({
|
|
|
261
269
|
endpoint,
|
|
262
270
|
refreshTrigger,
|
|
263
271
|
onCardClick,
|
|
272
|
+
onAction,
|
|
264
273
|
pageSize = 200,
|
|
265
274
|
timeZone,
|
|
266
275
|
currency,
|
|
@@ -280,11 +289,6 @@ export function DynamicKanban({
|
|
|
280
289
|
// Active drag card id (for the DragOverlay + drop-zone highlighting).
|
|
281
290
|
const [activeId, setActiveId] = useState<string | null>(null)
|
|
282
291
|
|
|
283
|
-
const [actionModal, setActionModal] = useState<{
|
|
284
|
-
action: ActionMetadata | null
|
|
285
|
-
record: any | null
|
|
286
|
-
}>({ action: null, record: null })
|
|
287
|
-
|
|
288
292
|
const sensors = useSensors(
|
|
289
293
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
290
294
|
)
|
|
@@ -360,8 +364,32 @@ export function DynamicKanban({
|
|
|
360
364
|
[metadata],
|
|
361
365
|
)
|
|
362
366
|
|
|
363
|
-
// Row-placement actions
|
|
364
|
-
|
|
367
|
+
// Row-placement actions resolved EXACTLY like DynamicTable's action column:
|
|
368
|
+
// capability-gated (when a <PermissionsProvider> is mounted) and with the
|
|
369
|
+
// implicit View/Edit/Delete trio materialized for CRUD models. An action the
|
|
370
|
+
// user lacks permission for never appears.
|
|
371
|
+
const can = useCan()
|
|
372
|
+
const permissionsActive = usePermissionsActive()
|
|
373
|
+
const rowActions = useMemo(
|
|
374
|
+
() =>
|
|
375
|
+
metadata
|
|
376
|
+
? resolveRowActions(metadata, model, can, permissionsActive, (k, fb) =>
|
|
377
|
+
t(k, { defaultValue: fb }),
|
|
378
|
+
)
|
|
379
|
+
: [],
|
|
380
|
+
[metadata, model, can, permissionsActive, t],
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
// Shared row-action dispatch + dialogs — view/edit/delete/link/custom behave
|
|
384
|
+
// identically to a table row (the card menu used to forward the raw action
|
|
385
|
+
// object to the host and silently no-op).
|
|
386
|
+
const { handleInternalAction, dialogs: rowActionDialogs } = useDynamicRowActions({
|
|
387
|
+
model,
|
|
388
|
+
endpoint,
|
|
389
|
+
metadata,
|
|
390
|
+
onAction,
|
|
391
|
+
onRefresh: fetchData,
|
|
392
|
+
})
|
|
365
393
|
|
|
366
394
|
const cardById = useMemo(() => {
|
|
367
395
|
const m = new Map<string, any>()
|
|
@@ -440,7 +468,7 @@ export function DynamicKanban({
|
|
|
440
468
|
return (
|
|
441
469
|
<div className="flex gap-4 overflow-x-auto p-1">
|
|
442
470
|
{[0, 1, 2, 3].map((i) => (
|
|
443
|
-
<div key={i} className="
|
|
471
|
+
<div key={i} className="w-[300px] shrink-0 space-y-3">
|
|
444
472
|
<Skeleton className="h-8 w-full" />
|
|
445
473
|
<Skeleton className="h-24 w-full" />
|
|
446
474
|
<Skeleton className="h-24 w-full" />
|
|
@@ -513,9 +541,7 @@ export function DynamicKanban({
|
|
|
513
541
|
timeZone={timeZone}
|
|
514
542
|
currency={currency}
|
|
515
543
|
onClick={onCardClick}
|
|
516
|
-
onAction={
|
|
517
|
-
setActionModal({ action, record })
|
|
518
|
-
}
|
|
544
|
+
onAction={handleInternalAction}
|
|
519
545
|
/>
|
|
520
546
|
))
|
|
521
547
|
)}
|
|
@@ -537,22 +563,7 @@ export function DynamicKanban({
|
|
|
537
563
|
) : null}
|
|
538
564
|
</DragOverlay>
|
|
539
565
|
|
|
540
|
-
{
|
|
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
|
-
)}
|
|
566
|
+
{rowActionDialogs}
|
|
556
567
|
</DndContext>
|
|
557
568
|
)
|
|
558
569
|
}
|
|
@@ -579,7 +590,7 @@ function KanbanLane({ stage, count, isDark, dimmed, disabled, children }: Kanban
|
|
|
579
590
|
return (
|
|
580
591
|
<div
|
|
581
592
|
ref={setNodeRef}
|
|
582
|
-
className="flex
|
|
593
|
+
className="flex w-[300px] shrink-0 flex-col rounded-lg border bg-muted/30 transition-opacity"
|
|
583
594
|
style={{
|
|
584
595
|
opacity: dimmed ? 0.45 : 1,
|
|
585
596
|
outline: isOver && !disabled ? '2px solid var(--ring, #3b82f6)' : 'none',
|
|
@@ -621,12 +632,13 @@ interface KanbanCardProps {
|
|
|
621
632
|
card: any
|
|
622
633
|
titleCol: ColumnDefinition | null
|
|
623
634
|
fieldCols: ColumnDefinition[]
|
|
624
|
-
actions:
|
|
635
|
+
actions: ActionDefinition[]
|
|
625
636
|
locale: string
|
|
626
637
|
timeZone?: string
|
|
627
638
|
currency?: string
|
|
628
639
|
onClick?: (row: any) => void
|
|
629
|
-
|
|
640
|
+
/** STRING contract — dispatch the action by its key (see useDynamicRowActions). */
|
|
641
|
+
onAction: (actionKey: string, record: any) => void
|
|
630
642
|
}
|
|
631
643
|
|
|
632
644
|
function KanbanCard({
|
|
@@ -644,9 +656,7 @@ function KanbanCard({
|
|
|
644
656
|
id: String(card.id),
|
|
645
657
|
})
|
|
646
658
|
|
|
647
|
-
const visibleActions =
|
|
648
|
-
isActionAllowedForRowState(a, card),
|
|
649
|
-
)
|
|
659
|
+
const visibleActions = actions.filter((a) => isRowActionVisible(a, card))
|
|
650
660
|
|
|
651
661
|
return (
|
|
652
662
|
<Card
|
|
@@ -693,7 +703,7 @@ function KanbanCard({
|
|
|
693
703
|
key={a.key}
|
|
694
704
|
onClick={(e) => {
|
|
695
705
|
e.stopPropagation()
|
|
696
|
-
onAction(a
|
|
706
|
+
onAction(a.key, card)
|
|
697
707
|
}}
|
|
698
708
|
>
|
|
699
709
|
<DynamicIcon
|
|
@@ -739,7 +749,7 @@ function CardPreview({
|
|
|
739
749
|
currency,
|
|
740
750
|
}: Omit<KanbanCardProps, 'actions' | 'onClick' | 'onAction'>) {
|
|
741
751
|
return (
|
|
742
|
-
<Card className="w-
|
|
752
|
+
<Card className="w-[284px] cursor-grabbing border-primary/40 shadow-lg">
|
|
743
753
|
<CardContent className="space-y-1.5 p-3">
|
|
744
754
|
<div className="break-words text-sm font-medium leading-snug">
|
|
745
755
|
{titleCol ? (
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// useDynamicRowActions — the single source of truth for what a per-row action
|
|
2
|
+
// (the "…" kebab → Ver / Editar / Eliminar / custom / link) DOES when clicked.
|
|
3
|
+
//
|
|
4
|
+
// Extracted verbatim from DynamicTable so the kanban board's per-card menu
|
|
5
|
+
// behaves IDENTICALLY to a table row (it used to forward the raw action object
|
|
6
|
+
// to the host and silently no-op). Both renderers call the returned
|
|
7
|
+
// `handleInternalAction(actionKey, row)` with the action's STRING key and mount
|
|
8
|
+
// the returned `dialogs` node once in their tree:
|
|
9
|
+
//
|
|
10
|
+
// delete → opens the SDK's own confirm dialog, then DELETEs.
|
|
11
|
+
// view / edit → host `onAction(action, row)` when provided (string
|
|
12
|
+
// contract), else the built-in record dialog.
|
|
13
|
+
// link action → navigate to the action's templated `linkUrl`.
|
|
14
|
+
// custom (fields/confirm/
|
|
15
|
+
// executable) action → opens the ActionModal via the dispatcher.
|
|
16
|
+
// anything else → host `onAction` + refresh (or just refresh).
|
|
17
|
+
//
|
|
18
|
+
// The host `onAction` contract is a STRING action key on purpose — the existing
|
|
19
|
+
// ops `handleAction(action: string, row)` keeps working unchanged.
|
|
20
|
+
import { useCallback, useState } from 'react'
|
|
21
|
+
import { useNavigate } from '@tanstack/react-router'
|
|
22
|
+
import { useTranslation } from 'react-i18next'
|
|
23
|
+
import { toast } from 'sonner'
|
|
24
|
+
import {
|
|
25
|
+
AlertDialog,
|
|
26
|
+
AlertDialogAction,
|
|
27
|
+
AlertDialogCancel,
|
|
28
|
+
AlertDialogContent,
|
|
29
|
+
AlertDialogDescription,
|
|
30
|
+
AlertDialogFooter,
|
|
31
|
+
AlertDialogHeader,
|
|
32
|
+
AlertDialogTitle,
|
|
33
|
+
} from '@asteby/metacore-ui/primitives'
|
|
34
|
+
import { useApi } from './api-context'
|
|
35
|
+
import { ActionModalDispatcher } from './action-modal-dispatcher'
|
|
36
|
+
import { DynamicRecordDialog } from './dialogs/dynamic-record'
|
|
37
|
+
import type { TableMetadata, ActionMetadata } from './types'
|
|
38
|
+
|
|
39
|
+
export interface UseDynamicRowActionsParams {
|
|
40
|
+
/** Model key as registered on the backend (e.g. "issue"). */
|
|
41
|
+
model: string
|
|
42
|
+
/**
|
|
43
|
+
* Data endpoint base. The per-record DELETE hits `<endpoint>/<id>`; when
|
|
44
|
+
* omitted it falls back to `/data/<model>/<id>`. Pass the SAME base the
|
|
45
|
+
* caller lists from (e.g. `/data/<model>/me`) so writes stay org-scoped.
|
|
46
|
+
*/
|
|
47
|
+
endpoint?: string
|
|
48
|
+
/** Raw model metadata — used to resolve link/custom action definitions. */
|
|
49
|
+
metadata: TableMetadata | null
|
|
50
|
+
/**
|
|
51
|
+
* Host hook for `view`/`edit` (and any unhandled key). STRING contract:
|
|
52
|
+
* receives the action key, not the action object. When absent, `view`/`edit`
|
|
53
|
+
* fall back to the built-in record dialog.
|
|
54
|
+
*/
|
|
55
|
+
onAction?: (action: string, row: any) => void
|
|
56
|
+
/** Called after a successful delete / custom action to refetch the list. */
|
|
57
|
+
onRefresh: () => void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DynamicRowActions {
|
|
61
|
+
/** Dispatch a row action by its STRING key. */
|
|
62
|
+
handleInternalAction: (action: string, row: any) => Promise<void>
|
|
63
|
+
/**
|
|
64
|
+
* The delete-confirm dialog + record dialog + action modal this handler
|
|
65
|
+
* drives. Render it ONCE in the consuming component's tree.
|
|
66
|
+
*/
|
|
67
|
+
dialogs: React.ReactElement
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Owns the state + dialogs + dispatch logic for per-row actions, shared by
|
|
72
|
+
* DynamicTable and DynamicKanban so both behave identically.
|
|
73
|
+
*/
|
|
74
|
+
export function useDynamicRowActions({
|
|
75
|
+
model,
|
|
76
|
+
endpoint,
|
|
77
|
+
metadata,
|
|
78
|
+
onAction,
|
|
79
|
+
onRefresh,
|
|
80
|
+
}: UseDynamicRowActionsParams): DynamicRowActions {
|
|
81
|
+
const { t } = useTranslation()
|
|
82
|
+
const api = useApi()
|
|
83
|
+
const navigate = useNavigate()
|
|
84
|
+
|
|
85
|
+
const [recordDialog, setRecordDialog] = useState<{
|
|
86
|
+
open: boolean
|
|
87
|
+
mode: 'view' | 'edit' | 'create'
|
|
88
|
+
recordId: string | null
|
|
89
|
+
}>({ open: false, mode: 'view', recordId: null })
|
|
90
|
+
|
|
91
|
+
const [rowToDelete, setRowToDelete] = useState<any | null>(null)
|
|
92
|
+
const [isDeleting, setIsDeleting] = useState(false)
|
|
93
|
+
|
|
94
|
+
const [actionModal, setActionModal] = useState<{
|
|
95
|
+
open: boolean
|
|
96
|
+
action: ActionMetadata | null
|
|
97
|
+
record: any | null
|
|
98
|
+
}>({ open: false, action: null, record: null })
|
|
99
|
+
|
|
100
|
+
const handleInternalAction = useCallback(async (action: string, row: any) => {
|
|
101
|
+
if (action === 'delete') { setRowToDelete(row); return }
|
|
102
|
+
if (action === 'view' || action === 'edit') {
|
|
103
|
+
if (onAction) await Promise.resolve(onAction(action, row))
|
|
104
|
+
else setRecordDialog({ open: true, mode: action, recordId: row.id })
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
const linkDef = metadata?.actions?.find((a) => a.key === action && a.type === 'link')
|
|
108
|
+
if (linkDef?.linkUrl) {
|
|
109
|
+
const url = linkDef.linkUrl.replace(/\{(\w+)\}/g, (_: string, field: string) => String(row[field] ?? ''))
|
|
110
|
+
navigate({ to: url })
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
const actionDef = metadata?.actions?.find((a) => a.key === action)
|
|
114
|
+
if (actionDef && (actionDef.fields?.length || actionDef.confirm || actionDef.executable)) {
|
|
115
|
+
setActionModal({
|
|
116
|
+
open: true,
|
|
117
|
+
action: {
|
|
118
|
+
key: actionDef.key,
|
|
119
|
+
label: actionDef.label,
|
|
120
|
+
icon: actionDef.icon || 'Zap',
|
|
121
|
+
color: actionDef.color,
|
|
122
|
+
confirm: actionDef.confirm,
|
|
123
|
+
confirmMessage: actionDef.confirmMessage,
|
|
124
|
+
fields: actionDef.fields,
|
|
125
|
+
requiresState: actionDef.requiresState,
|
|
126
|
+
executable: actionDef.executable,
|
|
127
|
+
},
|
|
128
|
+
record: row,
|
|
129
|
+
})
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
if (onAction) { await Promise.resolve(onAction(action, row)); onRefresh() }
|
|
133
|
+
else onRefresh()
|
|
134
|
+
}, [onAction, onRefresh, metadata, navigate])
|
|
135
|
+
|
|
136
|
+
const confirmDelete = async () => {
|
|
137
|
+
if (!rowToDelete) return
|
|
138
|
+
setIsDeleting(true)
|
|
139
|
+
try {
|
|
140
|
+
const deleteEndpoint = endpoint ? `${endpoint}/${rowToDelete.id}` : `/data/${model}/${rowToDelete.id}`
|
|
141
|
+
const res = await api.delete(deleteEndpoint)
|
|
142
|
+
if (res.data.success) { toast.success(res.data.message || 'Eliminado correctamente'); onRefresh() }
|
|
143
|
+
else toast.error(res.data.message || 'Error al eliminar')
|
|
144
|
+
} catch (error) {
|
|
145
|
+
console.error('Error al eliminar', error)
|
|
146
|
+
toast.error('Error al eliminar el registro')
|
|
147
|
+
} finally {
|
|
148
|
+
setIsDeleting(false)
|
|
149
|
+
setRowToDelete(null)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const dialogs = (
|
|
154
|
+
<>
|
|
155
|
+
<AlertDialog open={!!rowToDelete} onOpenChange={(open: boolean) => !open && setRowToDelete(null)}>
|
|
156
|
+
<AlertDialogContent>
|
|
157
|
+
<AlertDialogHeader>
|
|
158
|
+
<AlertDialogTitle>¿Está absolutamente seguro?</AlertDialogTitle>
|
|
159
|
+
<AlertDialogDescription>Esta acción no se puede deshacer. Esto eliminará permanentemente el registro seleccionado de nuestros servidores.</AlertDialogDescription>
|
|
160
|
+
</AlertDialogHeader>
|
|
161
|
+
<AlertDialogFooter>
|
|
162
|
+
<AlertDialogCancel disabled={isDeleting}>{t('common.cancel')}</AlertDialogCancel>
|
|
163
|
+
<AlertDialogAction onClick={(e: React.MouseEvent) => { e.preventDefault(); confirmDelete() }} className="bg-red-600 hover:bg-red-700" disabled={isDeleting}>
|
|
164
|
+
{isDeleting ? 'Eliminando...' : 'Eliminar'}
|
|
165
|
+
</AlertDialogAction>
|
|
166
|
+
</AlertDialogFooter>
|
|
167
|
+
</AlertDialogContent>
|
|
168
|
+
</AlertDialog>
|
|
169
|
+
|
|
170
|
+
<DynamicRecordDialog
|
|
171
|
+
open={recordDialog.open}
|
|
172
|
+
onOpenChange={(open: boolean) => setRecordDialog((prev) => ({ ...prev, open }))}
|
|
173
|
+
mode={recordDialog.mode}
|
|
174
|
+
model={model}
|
|
175
|
+
recordId={recordDialog.recordId}
|
|
176
|
+
endpoint={endpoint}
|
|
177
|
+
onSaved={onRefresh}
|
|
178
|
+
/>
|
|
179
|
+
|
|
180
|
+
{actionModal.action && (
|
|
181
|
+
<ActionModalDispatcher
|
|
182
|
+
open={actionModal.open}
|
|
183
|
+
onOpenChange={(open: boolean) => setActionModal((prev) => ({ ...prev, open }))}
|
|
184
|
+
action={actionModal.action}
|
|
185
|
+
model={model}
|
|
186
|
+
record={actionModal.record}
|
|
187
|
+
endpoint={endpoint}
|
|
188
|
+
onSuccess={onRefresh}
|
|
189
|
+
/>
|
|
190
|
+
)}
|
|
191
|
+
</>
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
return { handleInternalAction, dialogs }
|
|
195
|
+
}
|