@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/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,EAAoB,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"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "20.1.5",
3
+ "version": "20.1.7",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- 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 {
@@ -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
@@ -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 { isActionAllowedForRowState } from './dynamic-columns'
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
- ActionMetadata,
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 reused verbatim from the table's plumbing.
364
- const rowActions = useModelActions(model, ['row'], metadata?.actions)
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="min-w-[220px] max-w-[320px] flex-1 space-y-3">
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={(action, record) =>
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
- {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
- )}
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 min-w-[220px] max-w-[320px] flex-1 flex-col rounded-lg border bg-muted/30 transition-opacity"
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: ActionMetadata[] | any[]
635
+ actions: ActionDefinition[]
625
636
  locale: string
626
637
  timeZone?: string
627
638
  currency?: string
628
639
  onClick?: (row: any) => void
629
- onAction: (action: ActionMetadata, record: any) => void
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 = (actions as any[]).filter((a) =>
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 as ActionMetadata, card)
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-72 cursor-grabbing border-primary/40 shadow-lg">
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
+ }