@asteby/metacore-runtime-react 49.9.1 → 49.10.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.
@@ -0,0 +1,104 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // `allowedRoles` por acción: oculta según los roles que provee el host vía
4
+ // <PermissionsProvider roles superRoles>. Solo UX; el backend es la autoridad.
5
+ import { describe, expect, it } from 'vitest'
6
+ import { renderHook } from '@testing-library/react'
7
+ import React from 'react'
8
+
9
+ import {
10
+ gateTableMetadata,
11
+ isActionAllowedForRoles,
12
+ makeCan,
13
+ PermissionsProvider,
14
+ resolveRowActions,
15
+ useRoleGate,
16
+ type RoleGate,
17
+ } from '../permissions-context'
18
+ import type { TableMetadata, ActionDefinition } from '../types'
19
+
20
+ function meta(actions: ActionDefinition[]): TableMetadata {
21
+ return {
22
+ title: 'Doctores',
23
+ endpoint: '/dynamic/doctors',
24
+ columns: [],
25
+ actions,
26
+ perPageOptions: [10],
27
+ defaultPerPage: 10,
28
+ searchPlaceholder: 'Buscar...',
29
+ enableCRUDActions: false,
30
+ hasActions: actions.length > 0,
31
+ } as TableMetadata
32
+ }
33
+
34
+ const act = (key: string, extra: Record<string, unknown> = {}): ActionDefinition =>
35
+ ({ key, name: key, label: key, icon: 'Zap', ...extra }) as ActionDefinition
36
+
37
+ const can = makeCan(['*'], false)
38
+ const gate = (over: Partial<RoleGate> = {}): RoleGate => ({
39
+ roles: ['doctor'],
40
+ superRoles: [],
41
+ loading: false,
42
+ isAdmin: false,
43
+ ...over,
44
+ })
45
+ const keys = (m: TableMetadata) => m.actions.map((a) => a.key)
46
+
47
+ describe('allowedRoles por acción', () => {
48
+ const m = meta([act('free'), act('onlyAdmin', { allowedRoles: ['admin'] }), act('docs', { allowedRoles: ['doctor', 'nurse'] })])
49
+
50
+ it('oculta/muestra según el rol del usuario', () => {
51
+ expect(keys(gateTableMetadata(m, 'doctors', can, undefined, gate()))).toEqual(['free', 'docs'])
52
+ expect(keys(gateTableMetadata(m, 'doctors', can, undefined, gate({ roles: ['admin'] })))).toEqual(['free', 'onlyAdmin'])
53
+ })
54
+
55
+ it('superRoles hace bypass; sin superRoles no hay bypass', () => {
56
+ const g = gate({ roles: ['super_admin'], superRoles: ['admin', 'super_admin'] })
57
+ expect(keys(gateTableMetadata(m, 'doctors', can, undefined, g))).toEqual(['free', 'onlyAdmin', 'docs'])
58
+ expect(keys(gateTableMetadata(m, 'doctors', can, undefined, gate({ roles: ['super_admin'] })))).toEqual(['free'])
59
+ })
60
+
61
+ it('isAdmin hace bypass', () => {
62
+ expect(keys(gateTableMetadata(m, 'doctors', can, undefined, gate({ isAdmin: true })))).toHaveLength(3)
63
+ })
64
+
65
+ it('sin allowedRoles (o vacío) todo igual', () => {
66
+ const plain = meta([act('a'), act('b', { allowedRoles: [] })])
67
+ expect(keys(gateTableMetadata(plain, 'doctors', can, undefined, gate({ roles: [] })))).toEqual(['a', 'b'])
68
+ })
69
+
70
+ it('sin provider (gate null) todo igual', () => {
71
+ expect(keys(gateTableMetadata(m, 'doctors', can))).toEqual(['free', 'onlyAdmin', 'docs'])
72
+ expect(isActionAllowedForRoles(act('x', { allowedRoles: ['admin'] }), null)).toBe(true)
73
+ })
74
+
75
+ it('acepta snake_case allowed_roles', () => {
76
+ const s = meta([act('x', { allowed_roles: ['admin'] })])
77
+ expect(keys(gateTableMetadata(s, 'doctors', can, undefined, gate()))).toEqual([])
78
+ expect(keys(gateTableMetadata(s, 'doctors', can, undefined, gate({ roles: ['admin'] })))).toEqual(['x'])
79
+ })
80
+
81
+ it('loading y roles sin resolver no filtran; roles [] es fail-closed', () => {
82
+ const a = act('x', { allowedRoles: ['admin'] })
83
+ expect(isActionAllowedForRoles(a, gate({ roles: undefined, loading: true }))).toBe(true)
84
+ expect(isActionAllowedForRoles(a, gate({ roles: ['patient'], loading: true }))).toBe(true)
85
+ expect(isActionAllowedForRoles(a, gate({ roles: undefined }))).toBe(true)
86
+ expect(isActionAllowedForRoles(a, gate({ roles: [] }))).toBe(false)
87
+ })
88
+
89
+ it('resolveRowActions (kanban) aplica el mismo filtro', () => {
90
+ const rows = resolveRowActions(m, 'doctors', can, true, undefined, gate())
91
+ expect(rows.map((a) => a.key)).toEqual(['free', 'docs'])
92
+ })
93
+
94
+ it('PermissionsProvider expone el gate y sin provider es null', () => {
95
+ expect(renderHook(() => useRoleGate()).result.current).toBeNull()
96
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
97
+ <PermissionsProvider permissions={['*']} isAdmin={false} roles={['doctor']} superRoles={['admin']}>
98
+ {children}
99
+ </PermissionsProvider>
100
+ )
101
+ const { result } = renderHook(() => useRoleGate(), { wrapper })
102
+ expect(result.current).toMatchObject({ roles: ['doctor'], superRoles: ['admin'], loading: false })
103
+ })
104
+ })
@@ -0,0 +1,172 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // `isRowActionVisible` (consumer predicate) en DynamicTable y DynamicKanban:
4
+ // 1. Función pura: AND con requiresState/condition, fail-closed si lanza,
5
+ // sin predicado = comportamiento previo.
6
+ // 2. DynamicTable: el menú «…» de una fila desaparece cuando el predicado
7
+ // oculta todas sus acciones; sin predicado aparece en todas.
8
+ // 3. DynamicKanban: igual para el menú de la tarjeta.
9
+ import { afterEach, describe, expect, it, vi } from 'vitest'
10
+ import { cleanup, render, screen, waitFor } from '@testing-library/react'
11
+
12
+ vi.mock('@tanstack/react-router', () => ({
13
+ useNavigate: () => () => {},
14
+ }))
15
+ const I18N_T = (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k
16
+ const USE_TRANSLATION = { t: I18N_T, i18n: { language: 'es' } }
17
+ vi.mock('react-i18next', () => ({ useTranslation: () => USE_TRANSLATION }))
18
+
19
+ import { isRowActionVisible } from '../dynamic-columns'
20
+ import { DynamicTable } from '../dynamic-table'
21
+ import { DynamicKanban } from '../dynamic-kanban'
22
+ import { ApiProvider, type ApiClient } from '../api-context'
23
+ import { useMetadataCache } from '../metadata-cache'
24
+ import type { ActionDefinition, TableMetadata } from '../types'
25
+ import type { RowActionPredicate } from '../dynamic-columns-shim'
26
+
27
+ afterEach(() => {
28
+ cleanup()
29
+ vi.restoreAllMocks()
30
+ })
31
+
32
+ const act = (over: Record<string, unknown> = {}): ActionDefinition =>
33
+ ({ key: 'pagar', name: 'pagar', label: 'Pagar', icon: 'Zap', ...over }) as ActionDefinition
34
+
35
+ describe('isRowActionVisible (puro)', () => {
36
+ it('sin predicado = comportamiento previo (requiresState + condition)', () => {
37
+ const a = act({ requiresState: ['open'] })
38
+ expect(isRowActionVisible(a, { status: 'open' })).toBe(true)
39
+ expect(isRowActionVisible(a, { status: 'closed' })).toBe(false)
40
+ })
41
+
42
+ it('muestra u oculta por fila según el predicado y recibe (action, row)', () => {
43
+ const spy = vi.fn<RowActionPredicate>((_a, row) => row.id !== 2)
44
+ const a = act()
45
+ expect(isRowActionVisible(a, { id: 1 }, undefined, spy)).toBe(true)
46
+ expect(isRowActionVisible(a, { id: 2 }, undefined, spy)).toBe(false)
47
+ expect(spy).toHaveBeenCalledWith(a, { id: 2 })
48
+ })
49
+
50
+ it('es AND: un predicado true no revela lo que la metadata oculta', () => {
51
+ const a = act({ requiresState: ['open'] })
52
+ expect(isRowActionVisible(a, { status: 'closed' }, undefined, () => true)).toBe(false)
53
+ expect(isRowActionVisible(a, { status: 'open' }, undefined, () => false)).toBe(false)
54
+ expect(isRowActionVisible(a, { status: 'open' }, undefined, () => true)).toBe(true)
55
+ })
56
+
57
+ it('si el predicado lanza, oculta la acción y registra el error', () => {
58
+ const err = vi.spyOn(console, 'error').mockImplementation(() => {})
59
+ const boom: RowActionPredicate = () => {
60
+ throw new Error('boom')
61
+ }
62
+ expect(isRowActionVisible(act(), { id: 1 }, undefined, boom)).toBe(false)
63
+ expect(err).toHaveBeenCalled()
64
+ })
65
+ })
66
+
67
+ function meta(over: Partial<TableMetadata> = {}): TableMetadata {
68
+ return {
69
+ title: 'Issues',
70
+ endpoint: '/data/issue',
71
+ columns: [{ key: 'title', label: 'Title', type: 'text', sortable: false, filterable: false, searchable: true }],
72
+ actions: [act()],
73
+ perPageOptions: [10],
74
+ defaultPerPage: 10,
75
+ searchPlaceholder: 'Buscar...',
76
+ enableCRUDActions: false,
77
+ hasActions: true,
78
+ ...over,
79
+ }
80
+ }
81
+
82
+ const ROWS = [
83
+ { id: 1, title: 'Uno', stage: 'a' },
84
+ { id: 2, title: 'Dos', stage: 'a' },
85
+ { id: 3, title: 'Tres', stage: 'a' },
86
+ ]
87
+
88
+ function fakeApi(m: TableMetadata): ApiClient {
89
+ const ok = (data: unknown) => ({ data: { success: true, data, meta: { total: ROWS.length } } })
90
+ return {
91
+ get: vi.fn(async (url: string) => {
92
+ if (url.startsWith('/metadata/table/')) return ok(m)
93
+ if (url.endsWith('/facets')) return ok([])
94
+ return ok(ROWS)
95
+ }),
96
+ post: vi.fn(async () => ok(null)),
97
+ put: vi.fn(async () => ok(null)),
98
+ delete: vi.fn(async () => ok(null)),
99
+ }
100
+ }
101
+
102
+ const hideRow2: RowActionPredicate = (_a, row) => row.id !== 2
103
+
104
+ // La tabla pinta el layout de escritorio y el de tarjetas móviles a la vez en
105
+ // el DOM (el CSS elige uno), así que cada fila cuenta dos veces.
106
+ const LAYOUTS = 2
107
+
108
+ describe('DynamicTable isRowActionVisible', () => {
109
+ const mount = (predicate?: RowActionPredicate) => {
110
+ const m = meta()
111
+ useMetadataCache.getState().setMetadata('issue', m)
112
+ return render(
113
+ <ApiProvider client={fakeApi(m)}>
114
+ <DynamicTable model="issue" enableUrlSync={false} isRowActionVisible={predicate} />
115
+ </ApiProvider>,
116
+ )
117
+ }
118
+
119
+ it('sin predicado todas las filas muestran el menú', async () => {
120
+ mount()
121
+ await screen.findAllByText('Dos')
122
+ expect(screen.getAllByText('Abrir menú')).toHaveLength(3 * LAYOUTS)
123
+ })
124
+
125
+ it('con predicado oculta el menú solo en la fila filtrada', async () => {
126
+ mount(hideRow2)
127
+ await screen.findAllByText('Dos')
128
+ await waitFor(() => expect(screen.getAllByText('Abrir menú')).toHaveLength(2 * LAYOUTS))
129
+ })
130
+
131
+ it('un predicado que lanza no rompe la tabla: oculta las acciones', async () => {
132
+ vi.spyOn(console, 'error').mockImplementation(() => {})
133
+ mount(() => {
134
+ throw new Error('boom')
135
+ })
136
+ await screen.findAllByText('Dos')
137
+ expect(screen.queryAllByText('Abrir menú')).toHaveLength(0)
138
+ })
139
+ })
140
+
141
+ describe('DynamicKanban isRowActionVisible', () => {
142
+ const mount = (predicate?: RowActionPredicate) => {
143
+ const m = meta({
144
+ view_type: 'kanban',
145
+ group_by: 'stage',
146
+ stages: [{ key: 'a', label: 'Etapa A', color: 'slate', order: 0 }],
147
+ columns: [
148
+ { key: 'title', label: 'Title', type: 'text', sortable: false, filterable: false, searchable: true },
149
+ { key: 'stage', label: 'Stage', type: 'status', sortable: false, filterable: false, options: [{ value: 'a', label: 'Etapa A', color: 'slate' }] },
150
+ ],
151
+ })
152
+ useMetadataCache.getState().setMetadata('issue', m)
153
+ return render(
154
+ <ApiProvider client={fakeApi(m)}>
155
+ <DynamicKanban model="issue" isRowActionVisible={predicate} />
156
+ </ApiProvider>,
157
+ )
158
+ }
159
+ const menus = (c: HTMLElement) => c.querySelectorAll('button[aria-haspopup="menu"]').length
160
+
161
+ it('sin predicado cada tarjeta tiene su menú', async () => {
162
+ const { container } = mount()
163
+ await screen.findAllByText('Dos')
164
+ expect(menus(container)).toBe(3)
165
+ })
166
+
167
+ it('con predicado la tarjeta filtrada pierde el menú', async () => {
168
+ const { container } = mount(hideRow2)
169
+ await screen.findAllByText('Dos')
170
+ await waitFor(() => expect(menus(container)).toBe(2))
171
+ })
172
+ })
@@ -4,7 +4,7 @@
4
4
  // shadcn theme). Hosts pass their implementation into <DynamicTable> via the
5
5
  // `getDynamicColumns` prop.
6
6
  import type { ColumnDef } from '@tanstack/react-table'
7
- import type { TableMetadata } from './types'
7
+ import type { ActionDefinition, TableMetadata } from './types'
8
8
 
9
9
  export interface FilterOption {
10
10
  label: string
@@ -31,6 +31,20 @@ export interface ColumnFilterConfig {
31
31
  loadOptions?: (q?: string) => Promise<FilterOption[]>
32
32
  }
33
33
 
34
+ /**
35
+ * Consumer-side, per-row gate for row actions. Receives the full action
36
+ * definition and the row; return `false` to hide the action for that row. It
37
+ * is AND-ed with the metadata gates (`requiresState` + `condition`), so it can
38
+ * only hide more, never reveal an action the metadata already hides. A throwing
39
+ * predicate hides the action (fail-closed) and logs via `console.error`.
40
+ *
41
+ * Pass a stable reference (module-level function or `useCallback`): a new
42
+ * identity on every render rebuilds the table columns.
43
+ *
44
+ * Adapting a `(actionKey, row)` predicate: `(a, row) => legacy(a.key, row)`.
45
+ */
46
+ export type RowActionPredicate = (action: ActionDefinition, row: Record<string, unknown>) => boolean
47
+
34
48
  /** Signature for the host-provided `getDynamicColumns` factory. */
35
49
  export type GetDynamicColumns = (
36
50
  metadata: TableMetadata,
@@ -40,6 +54,8 @@ export type GetDynamicColumns = (
40
54
  columnFilterConfigs: Map<string, ColumnFilterConfig>,
41
55
  timeZone?: string,
42
56
  currency?: string,
57
+ /** Consumer row-action predicate (`DynamicTable.isRowActionVisible`). Custom factories may ignore it. */
58
+ rowActionPredicate?: RowActionPredicate,
43
59
  ) => ColumnDef<any>[]
44
60
 
45
61
  /** Signature for the host-provided `DynamicIcon` renderer. */
@@ -59,6 +59,7 @@ import { isColumnVisibleInTable } from './column-visibility'
59
59
  import type {
60
60
  ColumnFilterConfig,
61
61
  GetDynamicColumns,
62
+ RowActionPredicate,
62
63
  } from './dynamic-columns-shim'
63
64
 
64
65
  /** Host-supplied helpers consumed by avatar/image cell renderers. */
@@ -75,6 +76,13 @@ export interface DynamicColumnsHelpers {
75
76
  * `import.meta.env.VITE_API_URL.replace('/api', '')`.
76
77
  */
77
78
  apiBaseUrl?: string
79
+ /**
80
+ * Optional host hook applied to the raw value of `type: 'image'` columns
81
+ * BEFORE it is resolved (absolute URL / rooted path / bare filename +
82
+ * basePath). Lets a host map its own storage convention (e.g. rewrite a
83
+ * legacy prefix) without the SDK hardcoding it. Omitted = no-op.
84
+ */
85
+ normalizeImagePath?: (raw: string, col: ColumnDefinition) => string
78
86
  }
79
87
 
80
88
  const defaultGetImageUrl = (path: string) => path
@@ -320,8 +328,22 @@ export const isActionConditionMet = (action: any, row: any): boolean => {
320
328
  * (`requiresState`) AND the declarative `condition` must pass. Shared by the
321
329
  * table's action column and the kanban card menu so they hide/show identically.
322
330
  */
323
- export const isRowActionVisible = (action: any, row: any, stageField?: string): boolean =>
324
- isActionAllowedForRowState(action, row, stageField) && isActionConditionMet(action, row)
331
+ export const isRowActionVisible = (
332
+ action: any,
333
+ row: any,
334
+ stageField?: string,
335
+ predicate?: RowActionPredicate,
336
+ ): boolean => {
337
+ if (!(isActionAllowedForRowState(action, row, stageField) && isActionConditionMet(action, row))) return false
338
+ if (!predicate) return true
339
+ // Consumer predicate: AND-ed with the metadata gates; fail-closed on throw.
340
+ try {
341
+ return !!predicate(action, row)
342
+ } catch (err) {
343
+ console.error('[metacore] isRowActionVisible predicate threw; hiding action', action?.key, err)
344
+ return false
345
+ }
346
+ }
325
347
 
326
348
  /**
327
349
  * The column a model's `requiresState` gates read: the served `stage_field`
@@ -649,6 +671,26 @@ export const resolveAvatarSrc = (
649
671
  return `${apiBaseUrl}${basePath}${raw}`
650
672
  }
651
673
 
674
+ /**
675
+ * Resolves the source of a `type: 'image'` cell with the same contract as
676
+ * `resolveAvatarSrc`: absolute `http(s)` URL and rooted `/path` are untouched;
677
+ * a bare filename gets `apiBaseUrl + basePath` only when the column declares a
678
+ * basePath (`styleConfig.base_path` / `col.basePath`), otherwise it is left
679
+ * as-is (previous behaviour). `normalizeImagePath` runs first. Exported for tests.
680
+ */
681
+ export const resolveImageSrc = (
682
+ col: ColumnDefinition,
683
+ raw: string,
684
+ apiBaseUrl = '',
685
+ normalizeImagePath?: (raw: string, col: ColumnDefinition) => string,
686
+ ): string => {
687
+ const value = normalizeImagePath ? normalizeImagePath(raw, col) : raw
688
+ if (value.startsWith('http') || value.startsWith('/')) return value
689
+ const basePath = styleCfg(col, 'base_path', 'basePath') ?? col.basePath ?? ''
690
+ if (!basePath) return value
691
+ return `${apiBaseUrl}${basePath}${value}`
692
+ }
693
+
652
694
  /**
653
695
  * Reads a secondary identifier the backend stamps on a resolved FK sibling — a
654
696
  * product's SKU, a user's email — projected as `subtitle`/`description` (the
@@ -880,6 +922,7 @@ export function makeDefaultGetDynamicColumns(
880
922
  ): GetDynamicColumns {
881
923
  const getImageUrl = helpers.getImageUrl ?? defaultGetImageUrl
882
924
  const apiBaseUrl = helpers.apiBaseUrl ?? ''
925
+ const normalizeImagePath = helpers.normalizeImagePath
883
926
 
884
927
  return function defaultGetDynamicColumns(
885
928
  metadata: TableMetadata,
@@ -889,6 +932,7 @@ export function makeDefaultGetDynamicColumns(
889
932
  filterConfigs?: Map<string, ColumnFilterConfig>,
890
933
  timeZone?: string,
891
934
  currency?: string,
935
+ rowActionPredicate?: RowActionPredicate,
892
936
  ): ColumnDef<any>[] {
893
937
  const dateLocale = currentLanguage === 'en' ? enUS : es
894
938
  const columns: ColumnDef<any>[] = [
@@ -1431,7 +1475,11 @@ export function makeDefaultGetDynamicColumns(
1431
1475
  (Array.isArray(row.original.media)
1432
1476
  ? row.original.media.find((m: any) => m.type === 'image')?.url
1433
1477
  : null)
1434
- return <ImageCell value={imageValue} getImageUrl={getImageUrl} />
1478
+ const imageSrc =
1479
+ imageValue && !isLucideIconName(imageValue)
1480
+ ? resolveImageSrc(col, String(imageValue), apiBaseUrl, normalizeImagePath)
1481
+ : imageValue
1482
+ return <ImageCell value={imageSrc} getImageUrl={getImageUrl} />
1435
1483
  }
1436
1484
 
1437
1485
  case 'image_stack': {
@@ -1545,7 +1593,7 @@ export function makeDefaultGetDynamicColumns(
1545
1593
  // secundarias (compartir/imprimir/correo/chat + las que
1546
1594
  // aporten addons instalados). Ver row-actions-menu.tsx.
1547
1595
  <RowActionsMenu
1548
- actions={resolvedActions.filter((action) => isRowActionVisible(action, row.original, lifecycleStageField(metadata)))}
1596
+ actions={resolvedActions.filter((action) => isRowActionVisible(action, row.original, lifecycleStageField(metadata), rowActionPredicate))}
1549
1597
  row={row.original}
1550
1598
  onAction={onAction}
1551
1599
  />
@@ -161,6 +161,7 @@ import { useOptionsResolver } from './use-options-resolver'
161
161
  import { ActivityValueRenderer } from './activity-value-renderer'
162
162
  import { DynamicIcon } from './dynamic-icon'
163
163
  import { isColumnVisibleInTable } from './column-visibility'
164
+ import type { RowActionPredicate } from './dynamic-columns-shim'
164
165
  import {
165
166
  aggregateOf,
166
167
  formatAggregateTotal,
@@ -169,7 +170,7 @@ import {
169
170
  relationKeyFor,
170
171
  RowActionMenuItem,
171
172
  } from './dynamic-columns'
172
- import { useCan, usePermissionsActive, resolveRowActions } from './permissions-context'
173
+ import { useCan, usePermissionsActive, useRoleGate, resolveRowActions } from './permissions-context'
173
174
  import { useDynamicRowActions } from './dynamic-row-actions'
174
175
  import { useStageLayout } from './stage-layout'
175
176
  import type {
@@ -844,6 +845,15 @@ export interface DynamicKanbanProps {
844
845
  * record dialog. `delete`/link/custom actions are always handled in-SDK.
845
846
  */
846
847
  onAction?: (action: string, row: any) => void
848
+ /**
849
+ * Consumer-side per-row gate for row actions, AND-ed with the metadata
850
+ * gates (`requiresState` + `condition`): it can only hide more. Receives the
851
+ * full action definition (adapt an `(actionKey, row)` predicate with
852
+ * `(a, row) => legacy(a.key, row)`). A throw hides the action (fail-closed,
853
+ * `console.error`). Memoize it (module-level fn or `useCallback`).
854
+ * Omitted → behaviour unchanged. Not a substitute for backend authorization.
855
+ */
856
+ isRowActionVisible?: RowActionPredicate
847
857
  /**
848
858
  * Size of the INITIAL board page (one request, grouped into lanes). Each
849
859
  * lane then tops up incrementally on scroll (see `lanePageSize`). Defaults
@@ -931,6 +941,7 @@ function DynamicKanbanBoard({
931
941
  realtime: realtimeProp,
932
942
  onCardClick,
933
943
  onAction,
944
+ isRowActionVisible: rowActionPredicate,
934
945
  pageSize = 50,
935
946
  lanePageSize = 25,
936
947
  timeZone: timeZoneProp,
@@ -1607,14 +1618,20 @@ function DynamicKanbanBoard({
1607
1618
  // user lacks permission for never appears.
1608
1619
  const can = useCan()
1609
1620
  const permissionsActive = usePermissionsActive()
1621
+ const roleGate = useRoleGate()
1610
1622
  const rowActions = useMemo(
1611
1623
  () =>
1612
1624
  metadata
1613
- ? resolveRowActions(metadata, model, can, permissionsActive, (k, fb) =>
1614
- t(k, { defaultValue: fb }),
1625
+ ? resolveRowActions(
1626
+ metadata,
1627
+ model,
1628
+ can,
1629
+ permissionsActive,
1630
+ (k, fb) => t(k, { defaultValue: fb }),
1631
+ roleGate,
1615
1632
  )
1616
1633
  : [],
1617
- [metadata, model, can, permissionsActive, t],
1634
+ [metadata, model, can, permissionsActive, t, roleGate],
1618
1635
  )
1619
1636
 
1620
1637
  // Shared row-action dispatch + dialogs — view/edit/delete/link/custom behave
@@ -2142,6 +2159,7 @@ function DynamicKanbanBoard({
2142
2159
  showAllFields={explicitCard}
2143
2160
  actions={rowActions}
2144
2161
  stageField={lifecycleStageField(metadata)}
2162
+ rowActionPredicate={rowActionPredicate}
2145
2163
  locale={i18n.language}
2146
2164
  timeZone={timeZone}
2147
2165
  currency={currency}
@@ -2175,6 +2193,7 @@ function DynamicKanbanBoard({
2175
2193
  showAllFields={explicitCard}
2176
2194
  actions={rowActions}
2177
2195
  stageField={lifecycleStageField(metadata)}
2196
+ rowActionPredicate={rowActionPredicate}
2178
2197
  locale={i18n.language}
2179
2198
  timeZone={timeZone}
2180
2199
  currency={currency}
@@ -3209,6 +3228,8 @@ interface KanbanCardProps {
3209
3228
  actions: ActionDefinition[]
3210
3229
  /** Column the actions' requiresState reads (the model's stage_field). */
3211
3230
  stageField?: string
3231
+ /** Consumer row-action predicate (`DynamicKanban.isRowActionVisible`). */
3232
+ rowActionPredicate?: RowActionPredicate
3212
3233
  locale: string
3213
3234
  timeZone?: string
3214
3235
  currency?: string
@@ -3287,6 +3308,7 @@ function KanbanCard({
3287
3308
  showAllFields,
3288
3309
  actions,
3289
3310
  stageField,
3311
+ rowActionPredicate,
3290
3312
  locale,
3291
3313
  timeZone,
3292
3314
  currency,
@@ -3301,7 +3323,7 @@ function KanbanCard({
3301
3323
  disabled: !draggable,
3302
3324
  })
3303
3325
 
3304
- const visibleActions = actions.filter((a) => isRowActionVisible(a, card, stageField))
3326
+ const visibleActions = actions.filter((a) => isRowActionVisible(a, card, stageField, rowActionPredicate))
3305
3327
 
3306
3328
  const menu =
3307
3329
  visibleActions.length > 0 ? (
@@ -69,7 +69,7 @@ import { useMetadataCache } from './metadata-cache'
69
69
  import { useRecordMutationTick } from './use-record-mutation-tick'
70
70
  import { useApi, useCurrentBranch } from './api-context'
71
71
  import { useReasonPrompt } from './reason-prompt'
72
- import type { ColumnFilterConfig, GetDynamicColumns } from './dynamic-columns-shim'
72
+ import type { ColumnFilterConfig, GetDynamicColumns, RowActionPredicate } from './dynamic-columns-shim'
73
73
  import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns'
74
74
  import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders'
75
75
  import { translateOptionLabels } from './filter-chips'
@@ -81,11 +81,11 @@ import {
81
81
  } from './table-virtualization'
82
82
  import { OptionsContext } from './options-context'
83
83
  import { RowActionsModelContext } from './row-actions-menu'
84
- import type { TableMetadata, ApiResponse, ColumnDefinition } from './types'
84
+ import type { TableMetadata, ApiResponse, ColumnDefinition, ActionDefinition } from './types'
85
85
  import { getSearchableColumnKeys } from './column-visibility'
86
86
  import { visibleRelationInclude } from './list-include'
87
87
  import { useDebouncedValue } from './use-debounced-value'
88
- import { useCan, usePermissionsActive, gateTableMetadata } from './permissions-context'
88
+ import { useCan, usePermissionsActive, useRoleGate, gateTableMetadata, modelCapability } from './permissions-context'
89
89
  import { useDynamicRowActions } from './dynamic-row-actions'
90
90
  import { ExportDialog } from './dialogs/export'
91
91
  import { ImportDialog } from './dialogs/import'
@@ -184,6 +184,13 @@ export interface DynamicTableProps {
184
184
  */
185
185
  mutationEndpoint?: string
186
186
  enableUrlSync?: boolean
187
+ /**
188
+ * Initial sort applied when there is no user/URL sorting yet. It seeds the
189
+ * `sorting` state, so a `?sortBy=` deep-link wins over it (when
190
+ * `enableUrlSync`), and the user can still change or clear the sort via
191
+ * the column headers. Only read on mount.
192
+ */
193
+ defaultSort?: { id: string; desc?: boolean }
187
194
  /**
188
195
  * Hide the import action on THIS view even when the model supports it.
189
196
  * A role-scoped view (e.g. a rep seeing only their own records) usually
@@ -213,6 +220,15 @@ export interface DynamicTableProps {
213
220
  * clickable and the behaviour is unchanged.
214
221
  */
215
222
  onRowClick?: (row: any) => void
223
+ /**
224
+ * Consumer-side per-row gate for row actions, AND-ed with the metadata
225
+ * gates (`requiresState` + `condition`): it can only hide more. Receives the
226
+ * full action definition (adapt an `(actionKey, row)` predicate with
227
+ * `(a, row) => legacy(a.key, row)`). A throw hides the action (fail-closed,
228
+ * `console.error`). Memoize it (module-level fn or `useCallback`).
229
+ * Omitted → behaviour unchanged. Not a substitute for backend authorization.
230
+ */
231
+ isRowActionVisible?: RowActionPredicate
216
232
  refreshTrigger?: any
217
233
  defaultFilters?: Record<string, any>
218
234
  extraColumns?: ColumnDef<any>[]
@@ -294,6 +310,10 @@ export interface DynamicTableBulkContext {
294
310
  selectedIds: Array<string | number>
295
311
  clearSelection: () => void
296
312
  refresh: () => void
313
+ /** Metadata actions visible to the current user (capability-gated when a PermissionsProvider is mounted). */
314
+ actions: ActionDefinition[]
315
+ /** `true` when the user may run `actionKey` on this model; always `true` without a PermissionsProvider. */
316
+ can: (actionKey: string) => boolean
297
317
  }
298
318
 
299
319
  /** True when an api-client error is an HTTP 403 (axios-style or fetch-style). */
@@ -307,12 +327,14 @@ export function DynamicTable({
307
327
  endpoint,
308
328
  mutationEndpoint,
309
329
  enableUrlSync = true,
330
+ defaultSort,
310
331
  hideImport,
311
332
  hideExport,
312
333
  hiddenColumns = [],
313
334
  allowedActionKeys,
314
335
  onAction,
315
336
  onRowClick,
337
+ isRowActionVisible: rowActionPredicate,
316
338
  refreshTrigger,
317
339
  defaultFilters,
318
340
  extraColumns = [],
@@ -395,7 +417,12 @@ export function DynamicTable({
395
417
  const [bulkDeleteTotal, setBulkDeleteTotal] = useState(0)
396
418
 
397
419
  const [rowSelection, setRowSelection] = useState({})
398
- const [sorting, setSorting] = useState<SortingState>([])
420
+ const [sorting, setSorting] = useState<SortingState>(() => {
421
+ if (!defaultSort) return []
422
+ // A deep-linked ?sortBy= wins; skip the default so no request goes out with it first.
423
+ if (enableUrlSync && typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('sortBy')) return []
424
+ return [{ id: defaultSort.id, desc: defaultSort.desc ?? false }]
425
+ })
399
426
  const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(() => {
400
427
  const initial: VisibilityState = {}
401
428
  hiddenColumns.forEach(col => { initial[col] = false })
@@ -799,10 +826,11 @@ export function DynamicTable({
799
826
  // filtered by `can(lowercase(model).<action>)`.
800
827
  const can = useCan()
801
828
  const permissionsActive = usePermissionsActive()
829
+ const roleGate = useRoleGate()
802
830
  const viewMetadata = useMemo(() => {
803
831
  if (!metadata || !permissionsActive) return metadata
804
- return gateTableMetadata(metadata, model, can, (key, fallback) => t(key, { defaultValue: fallback }))
805
- }, [metadata, permissionsActive, can, model, t])
832
+ return gateTableMetadata(metadata, model, can, (key, fallback) => t(key, { defaultValue: fallback }), roleGate)
833
+ }, [metadata, permissionsActive, can, model, t, roleGate])
806
834
 
807
835
  // Row-action menus mount their icons on open: load them with the
808
836
  // metadata so the first open draws every glyph at once.
@@ -1396,12 +1424,12 @@ export function DynamicTable({
1396
1424
  }
1397
1425
  return actions === viewMetadata.actions ? viewMetadata : { ...viewMetadata, actions }
1398
1426
  })()
1399
- const baseColumns = getDynamicColumns(rowMetadata, handleInternalAction, t, i18n.language, columnFilterConfigs, timeZone, currency)
1427
+ const baseColumns = getDynamicColumns(rowMetadata, handleInternalAction, t, i18n.language, columnFilterConfigs, timeZone, currency, rowActionPredicate)
1400
1428
  const filteredBase = baseColumns.filter((col: ColumnDef<any>) => !effectiveHiddenColumns.includes(col.id as string))
1401
1429
  const actionsCol = filteredBase.find((c: ColumnDef<any>) => c.id === 'actions')
1402
1430
  const otherCols = filteredBase.filter((c: ColumnDef<any>) => c.id !== 'actions')
1403
1431
  return [...otherCols, ...extraColumns, ...(actionsCol ? [actionsCol] : [])]
1404
- }, [viewMetadata, handleInternalAction, effectiveHiddenColumns, allowedActionKeys, extraColumns, t, i18n.language, columnFilterConfigs, getDynamicColumns, timeZone, currency])
1432
+ }, [viewMetadata, handleInternalAction, effectiveHiddenColumns, allowedActionKeys, extraColumns, t, i18n.language, columnFilterConfigs, getDynamicColumns, timeZone, currency, rowActionPredicate])
1405
1433
 
1406
1434
  const filters = useMemo(() => [], [])
1407
1435
 
@@ -1979,6 +2007,8 @@ export function DynamicTable({
1979
2007
  selectedIds: bulkSelectedRows.map((r) => r.id).filter((id) => id != null),
1980
2008
  clearSelection: () => table.resetRowSelection(),
1981
2009
  refresh: handleRefresh,
2010
+ actions: viewMetadata?.actions ?? [],
2011
+ can: (actionKey: string) => !permissionsActive || can(modelCapability(model, actionKey)),
1982
2012
  })
1983
2013
  : extraBulkActions}
1984
2014
  {!hideBulkDelete && (