@asteby/metacore-runtime-react 49.9.0 → 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.
Files changed (37) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/business/line-items-editor.js +2 -2
  3. package/dist/business/line-product-cell.js +1 -1
  4. package/dist/dynamic-columns-shim.d.ts +17 -2
  5. package/dist/dynamic-columns-shim.d.ts.map +1 -1
  6. package/dist/dynamic-columns.d.ts +17 -2
  7. package/dist/dynamic-columns.d.ts.map +1 -1
  8. package/dist/dynamic-columns.js +37 -4
  9. package/dist/dynamic-kanban.d.ts +10 -0
  10. package/dist/dynamic-kanban.d.ts.map +1 -1
  11. package/dist/dynamic-kanban.js +9 -8
  12. package/dist/dynamic-table.d.ts +26 -2
  13. package/dist/dynamic-table.d.ts.map +1 -1
  14. package/dist/dynamic-table.js +17 -7
  15. package/dist/index.d.ts +3 -3
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +2 -2
  18. package/dist/permissions-context.d.ts +64 -3
  19. package/dist/permissions-context.d.ts.map +1 -1
  20. package/dist/permissions-context.js +52 -6
  21. package/dist/types.d.ts +8 -0
  22. package/dist/types.d.ts.map +1 -1
  23. package/package.json +1 -1
  24. package/src/__tests__/document-editor-inline-selects.test.tsx +7 -7
  25. package/src/__tests__/dynamic-table-default-sort-bulk-ctx.test.tsx +195 -0
  26. package/src/__tests__/image-cell-basepath.test.tsx +72 -0
  27. package/src/__tests__/permissions-allowed-roles.test.tsx +104 -0
  28. package/src/__tests__/row-action-predicate.test.tsx +172 -0
  29. package/src/business/line-items-editor.tsx +8 -5
  30. package/src/business/line-product-cell.tsx +1 -1
  31. package/src/dynamic-columns-shim.ts +17 -1
  32. package/src/dynamic-columns.tsx +52 -4
  33. package/src/dynamic-kanban.tsx +27 -5
  34. package/src/dynamic-table.tsx +38 -8
  35. package/src/index.ts +5 -0
  36. package/src/permissions-context.tsx +99 -4
  37. package/src/types.ts +8 -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 && (
package/src/index.ts CHANGED
@@ -259,12 +259,15 @@ export {
259
259
  PermissionsProvider,
260
260
  useCan,
261
261
  usePermissionsActive,
262
+ useRoleGate,
263
+ isActionAllowedForRoles,
262
264
  makeCan,
263
265
  capabilityForActionKey,
264
266
  modelCapability,
265
267
  gateTableMetadata,
266
268
  resolveRowActions,
267
269
  type CanFn,
270
+ type RoleGate,
268
271
  type PermissionsProviderProps,
269
272
  } from './permissions-context'
270
273
  export {
@@ -423,6 +426,7 @@ export type {
423
426
  ColumnFilterConfig,
424
427
  FilterOption as DynamicColumnFilterOption,
425
428
  GetDynamicColumns,
429
+ RowActionPredicate,
426
430
  DynamicIconComponent,
427
431
  } from './dynamic-columns-shim'
428
432
  export {
@@ -430,6 +434,7 @@ export {
430
434
  makeDefaultGetDynamicColumns,
431
435
  relationKeyFor,
432
436
  resolveAvatarSrc,
437
+ resolveImageSrc,
433
438
  resolveMissingActorLabel,
434
439
  resolveActorDisplayName,
435
440
  resolveRelationLabel,
@@ -35,9 +35,44 @@ export interface PermissionsProviderProps {
35
35
  permissions: string[]
36
36
  /** Superrole bypass — admins/owners see everything, no filtering at all. */
37
37
  isAdmin: boolean
38
+ /**
39
+ * Roles of the current user (e.g. `['doctor']`), used ONLY to honour the
40
+ * per-action `allowedRoles` declared in table metadata. Opt-in: while this
41
+ * is `undefined` (and `loading` is not set) role filtering is OFF and
42
+ * `allowedRoles` is ignored, exactly as before this prop existed. Once the
43
+ * host passes an array (even `[]` = "resolved, no roles") the filter is
44
+ * fail-closed: an action with a non-empty `allowedRoles` that shares no
45
+ * role with the user is hidden.
46
+ *
47
+ * Pass a referentially stable array (`useMemo`, or a value straight from a
48
+ * store): the table rebuilds its columns when `roles` / `superRoles` change
49
+ * identity, so an inline `roles={[role]}` recreates them on every render.
50
+ */
51
+ roles?: string[]
52
+ /**
53
+ * Roles that bypass `allowedRoles` entirely (the host's superroles, e.g.
54
+ * `['admin', 'super_admin']`). Default `[]` = no role bypasses. `isAdmin`
55
+ * also bypasses. Same stable-reference advice as `roles`.
56
+ */
57
+ superRoles?: string[]
58
+ /**
59
+ * The host is still hydrating the session roles. While true no action is
60
+ * hidden by `allowedRoles` (avoids a flash of missing actions); set it back
61
+ * to false once `roles` is resolved.
62
+ */
63
+ rolesLoading?: boolean
38
64
  children: React.ReactNode
39
65
  }
40
66
 
67
+ /** Resolved role-gating state shared with the table surfaces. */
68
+ export interface RoleGate {
69
+ /** User roles; `undefined` while unresolved. */
70
+ roles?: string[]
71
+ superRoles: string[]
72
+ loading: boolean
73
+ isAdmin: boolean
74
+ }
75
+
41
76
  // ---------------------------------------------------------------------------
42
77
  // Core
43
78
  // ---------------------------------------------------------------------------
@@ -56,10 +91,64 @@ export function makeCan(permissions: string[], isAdmin: boolean): CanFn {
56
91
  const ALWAYS_ALLOW: CanFn = () => true
57
92
 
58
93
  const PermissionsContext = createContext<CanFn | null>(null)
94
+ const RoleGateContext = createContext<RoleGate | null>(null)
95
+
96
+ const NO_ROLES: string[] = []
59
97
 
60
- export function PermissionsProvider({ permissions, isAdmin, children }: PermissionsProviderProps) {
98
+ export function PermissionsProvider({
99
+ permissions,
100
+ isAdmin,
101
+ roles,
102
+ superRoles = NO_ROLES,
103
+ rolesLoading = false,
104
+ children,
105
+ }: PermissionsProviderProps) {
61
106
  const can = useMemo(() => makeCan(permissions, isAdmin), [permissions, isAdmin])
62
- return <PermissionsContext.Provider value={can}>{children}</PermissionsContext.Provider>
107
+ const roleGate = useMemo<RoleGate>(
108
+ () => ({ roles, superRoles, loading: rolesLoading, isAdmin }),
109
+ [roles, superRoles, rolesLoading, isAdmin],
110
+ )
111
+ return (
112
+ <PermissionsContext.Provider value={can}>
113
+ <RoleGateContext.Provider value={roleGate}>{children}</RoleGateContext.Provider>
114
+ </PermissionsContext.Provider>
115
+ )
116
+ }
117
+
118
+ /**
119
+ * Role-gating state of the nearest <PermissionsProvider>, or `null` when none
120
+ * is mounted (no provider → no role filtering, legacy behaviour).
121
+ */
122
+ export function useRoleGate(): RoleGate | null {
123
+ return useContext(RoleGateContext)
124
+ }
125
+
126
+ /**
127
+ * Whether `action` is visible for the user under `gate`. Pure.
128
+ *
129
+ * UX ONLY: this hides buttons, it does not authorize anything. The backend
130
+ * remains the authority and must enforce the same `allowedRoles` on execution.
131
+ *
132
+ * Rules:
133
+ * - no gate (no provider) → visible (unchanged behaviour).
134
+ * - action without `allowedRoles` (or empty) → visible. Reads both the
135
+ * camelCase `allowedRoles` and the snake_case `allowed_roles` wire forms.
136
+ * - `gate.loading` → visible (don't flash-hide while the session hydrates).
137
+ * - `gate.roles === undefined` and not loading → the host did not opt in to
138
+ * role gating → visible.
139
+ * - `gate.isAdmin` or any user role in `gate.superRoles` → visible.
140
+ * - otherwise visible only if the user shares a role with `allowedRoles`
141
+ * (fail-closed: `roles: []` sees none of the restricted actions).
142
+ */
143
+ export function isActionAllowedForRoles(action: ActionDefinition, gate: RoleGate | null): boolean {
144
+ if (!gate) return true
145
+ const declared = action.allowedRoles ?? action.allowed_roles
146
+ if (!declared || declared.length === 0) return true
147
+ if (gate.loading || gate.roles === undefined) return true
148
+ if (gate.isAdmin) return true
149
+ const userRoles = gate.roles
150
+ if (userRoles.some((r) => gate.superRoles.includes(r))) return true
151
+ return userRoles.some((r) => declared.includes(r))
63
152
  }
64
153
 
65
154
  /**
@@ -113,6 +202,10 @@ const DEFAULT_TRIO: { key: string; i18nKey: string; fallback: string; icon: stri
113
202
  * actions so individual entries can be dropped; `tx` resolves their labels
114
203
  * (defaults to the Spanish fallbacks used by the column factory).
115
204
  *
205
+ * When `roleGate` is given, actions declaring `allowedRoles` are also dropped
206
+ * for users outside those roles (see `isActionAllowedForRoles`; UX only, the
207
+ * backend stays the authority).
208
+ *
116
209
  * Pure + idempotent. Callers should only invoke it when a provider is active
117
210
  * (`usePermissionsActive()`), otherwise pass the metadata through untouched.
118
211
  */
@@ -121,6 +214,7 @@ export function gateTableMetadata(
121
214
  model: string,
122
215
  can: CanFn,
123
216
  tx: (i18nKey: string, fallback: string) => string = (_k, fallback) => fallback,
217
+ roleGate: RoleGate | null = null,
124
218
  ): TableMetadata {
125
219
  const allowed = (key: string) => can(modelCapability(model, key))
126
220
 
@@ -139,7 +233,7 @@ export function gateTableMetadata(
139
233
  }) as ActionDefinition,
140
234
  )
141
235
  : []
142
- const actions = base.filter((a) => allowed(a.key))
236
+ const actions = base.filter((a) => allowed(a.key) && isActionAllowedForRoles(a, roleGate))
143
237
 
144
238
  return {
145
239
  ...metadata,
@@ -176,8 +270,9 @@ export function resolveRowActions(
176
270
  can: CanFn,
177
271
  permissionsActive: boolean,
178
272
  tx: (i18nKey: string, fallback: string) => string = (_k, fallback) => fallback,
273
+ roleGate: RoleGate | null = null,
179
274
  ): ActionDefinition[] {
180
- const gated = permissionsActive ? gateTableMetadata(metadata, model, can, tx) : metadata
275
+ const gated = permissionsActive ? gateTableMetadata(metadata, model, can, tx, roleGate) : metadata
181
276
  const explicit = gated.actions ?? []
182
277
  const hasExplicit = (gated.hasActions ?? explicit.length > 0) && explicit.length > 0
183
278
  const base: ActionDefinition[] = hasExplicit
package/src/types.ts CHANGED
@@ -905,6 +905,14 @@ export interface ActionDefinition {
905
905
  /** Multi-step wizard form; when present the dispatcher renders a wizard. */
906
906
  steps?: ActionStep[]
907
907
  requiresState?: string[]
908
+ /**
909
+ * Roles allowed to see/run this action. Empty/absent = unrestricted. Only
910
+ * filters the UI when the host mounts <PermissionsProvider roles={...}>
911
+ * (see `isActionAllowedForRoles`); the backend remains the authority.
912
+ */
913
+ allowedRoles?: string[]
914
+ /** snake_case wire form of `allowedRoles`, read as a fallback. */
915
+ allowed_roles?: string[]
908
916
  /**
909
917
  * Manifest `supervisor_policy`: the action needs the on-the-spot
910
918
  * authorization of a supervisor for that policy (`general.approve_<policy>`).