@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.
- package/CHANGELOG.md +14 -0
- package/dist/dynamic-columns-shim.d.ts +17 -2
- package/dist/dynamic-columns-shim.d.ts.map +1 -1
- package/dist/dynamic-columns.d.ts +17 -2
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +37 -4
- package/dist/dynamic-kanban.d.ts +10 -0
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +9 -8
- package/dist/dynamic-table.d.ts +26 -2
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +17 -7
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/permissions-context.d.ts +64 -3
- package/dist/permissions-context.d.ts.map +1 -1
- package/dist/permissions-context.js +52 -6
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/dynamic-table-default-sort-bulk-ctx.test.tsx +195 -0
- package/src/__tests__/image-cell-basepath.test.tsx +72 -0
- package/src/__tests__/permissions-allowed-roles.test.tsx +104 -0
- package/src/__tests__/row-action-predicate.test.tsx +172 -0
- package/src/dynamic-columns-shim.ts +17 -1
- package/src/dynamic-columns.tsx +52 -4
- package/src/dynamic-kanban.tsx +27 -5
- package/src/dynamic-table.tsx +38 -8
- package/src/index.ts +5 -0
- package/src/permissions-context.tsx +99 -4
- package/src/types.ts +8 -0
|
@@ -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. */
|
package/src/dynamic-columns.tsx
CHANGED
|
@@ -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 = (
|
|
324
|
-
|
|
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
|
-
|
|
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
|
/>
|
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -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(
|
|
1614
|
-
|
|
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 ? (
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -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 && (
|