diff --git a/apps/console/record-header-preview.html b/apps/console/record-header-preview.html new file mode 100644 index 0000000000..62943c68ce --- /dev/null +++ b/apps/console/record-header-preview.html @@ -0,0 +1,15 @@ + + + + + + Record Header Actions Preview + + + +
+ + + diff --git a/apps/console/src/dev/DevRecordHeaderActions.tsx b/apps/console/src/dev/DevRecordHeaderActions.tsx new file mode 100644 index 0000000000..a78d0afe4a --- /dev/null +++ b/apps/console/src/dev/DevRecordHeaderActions.tsx @@ -0,0 +1,93 @@ +/** + * Dev-only harness for record-header `type:'api'` actions (objectui#3391). + * + * Reproduces the issue's exact declaration: an object action on + * `locations:['record_header']` whose target is + * `/api/v1/data/os_tianshun_ehr_production_plan/{id}`, with a required + * field-backed param, `bodyExtra` and a confirm dialog. Before the fix the + * header executor never stashed the record under `params._rowRecord`, so the + * PATCH went out with the literal `{id}` (`%7Bid%7D` on the wire → 400). + * + * After the fix, clicking 复制 → confirming → filling the param must produce + * PATCH /api/v1/data/os_tianshun_ehr_production_plan/plan-42 + * with `{ copy_ovr_start: …, copy_now: true }` in the body — observable in + * DevTools' Network panel (the request 404s/ECONNREFUSEDs without a backend; + * only the URL + body matter here). Not part of the product nav. + */ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { MetadataCtx, SchemaRendererProvider } from '@object-ui/react'; +import { RecordDetailView } from '@object-ui/app-shell'; + +const OBJECT_NAME = 'os_tianshun_ehr_production_plan'; +const RECORD_ID = 'plan-42'; + +const RECORD = { + id: RECORD_ID, + name: 'August production plan', + copy_ovr_start: '2026-08-01', +}; + +const OBJECT_DEF = { + name: OBJECT_NAME, + label: 'Production Plan', + fields: { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + copy_ovr_start: { type: 'date', label: 'Copy Start' }, + }, + actions: [ + { + name: 'copy_plan_row', + label: '复制', + locations: ['record_header'], + type: 'api', + method: 'PATCH', + target: `/api/v1/data/${OBJECT_NAME}/{id}`, + params: [{ field: 'copy_ovr_start', required: true }], + bodyExtra: { copy_now: true }, + confirmText: '确认复制该生产计划?', + }, + ], +}; + +const dataSource: any = { + find: async () => ({ data: [] }), + findOne: async () => RECORD, + create: async () => ({}), + update: async () => ({}), + delete: async () => ({}), +}; + +const METADATA: any = { + objects: [OBJECT_DEF], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], +}; + +export const DevRecordHeaderActions: React.FC = () => ( + + + +
+ {}} + objectNameOverride={OBJECT_NAME} + recordIdOverride={RECORD_ID} + embedded + /> +
+
+
+
+); + +export default DevRecordHeaderActions; diff --git a/apps/console/src/record-header-preview.tsx b/apps/console/src/record-header-preview.tsx new file mode 100644 index 0000000000..62fa3f1cd1 --- /dev/null +++ b/apps/console/src/record-header-preview.tsx @@ -0,0 +1,34 @@ +/** + * DEV-ONLY record-header api-action preview (objectui#3391). + * + * Mounts the REAL RecordDetailView with a stub dataSource and no backend so + * the `record_header` `type:'api'` `{field}`-interpolation fix can be + * browser-verified: clicking the header action must PATCH + * `/api/v1/data/os_tianshun_ehr_production_plan/plan-42` — not the literal + * `{id}` (`%7Bid%7D`) the bug sent. + * + * Served as a standalone Vite entry: open /record-header-preview.html. + * Excluded from the production build (no route references it). + */ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import './index.css'; +import { I18nProvider } from '@object-ui/i18n'; + +// Register the renderers the synthesized record page resolves through +// (side-effect imports — same set the preview gallery uses). +import '@object-ui/plugin-grid'; +import '@object-ui/plugin-form'; +import '@object-ui/plugin-view'; +import '@object-ui/plugin-list'; +import '@object-ui/plugin-detail'; + +import { DevRecordHeaderActions } from './dev/DevRecordHeaderActions'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/packages/app-shell/src/views/RecordDetailView.headerApiInterpolation.test.tsx b/packages/app-shell/src/views/RecordDetailView.headerApiInterpolation.test.tsx new file mode 100644 index 0000000000..6fe342fe7d --- /dev/null +++ b/packages/app-shell/src/views/RecordDetailView.headerApiInterpolation.test.tsx @@ -0,0 +1,291 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * RecordDetailView — `type:'api'` target URL `{field}` interpolation for + * record-header actions (objectui#3391). + * + * The record page's api handler used to gate `{field}` interpolation on + * `params._rowRecord` alone — a stash only related-list row dispatches + * carried. A `record_header` action targeting `/api/v1/data/:object/{id}` + * therefore went out with the literal `{id}` (`%7Bid%7D` on the wire → 400), + * while the SAME declaration on `list_item` interpolated fine. + * + * These tests exercise the handler set the view REALLY hands to its + * ActionProvider (captured via a pass-through wrapper, same harness as + * RecordDetailView.modalDispatch.test.tsx) and pin the fixed contract: + * 1. no stash → interpolate from THIS page's record; + * 2. a stashed `_rowRecord` still wins over the page record; + * 3. an action retargeting ANOTHER object never interpolates from the + * parent page record (wrong record's fields); + * 4. the flow trigger body never carries the client-side `_rowRecord` stash. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, waitFor, act, cleanup } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const authFetchSpy = vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), +); +vi.mock('@object-ui/auth', () => ({ + useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }), + createAuthenticatedFetch: () => authFetchSpy, +})); + +vi.mock('@object-ui/collaboration', () => ({ + useRecordPresence: () => [], + PresenceAvatars: () => null, +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }), +})); + +// The dialogs / flow runner are orthogonal chrome; stubbing them keeps this +// file about the api dispatch (same posture as the modal-dispatch tests). +vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null })); +vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null })); +vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null })); +vi.mock('./FlowRunner', () => ({ FlowRunner: () => null })); +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); + +vi.mock('../hooks/useActionModal', () => ({ + useActionModal: () => ({ + modalHandler: vi.fn(async () => ({ success: true })), + modalElement: null, + closeModal: () => {}, + resolveModalTarget: vi.fn(async () => null), + }), +})); + +vi.mock('../utils/consoleServerAction', () => ({ + createConsoleServerActionHandler: () => vi.fn(async () => ({ success: true })), +})); + +// Capture BOTH the handler set and the context each receives +// while KEEPING the real provider. The record page's own set is the only one +// carrying `approval`; the context capture lets the mount wait until the +// handlers were built AGAINST THE LOADED RECORD (apiHandler closes over +// `pageRecord`, so grabbing an earlier capture would test the null-record +// closure instead). +const captured: Array<{ handlers: Record Promise>; context: any }> = []; +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ActionProvider: (props: any) => { + captured.push({ handlers: props.handlers, context: props.context }); + return React.createElement(actual.ActionProvider as any, props); + }, + SchemaRenderer: () => null, + }; +}); + +import { MetadataCtx } from '@object-ui/react'; +import { RecordDetailView } from './RecordDetailView'; + +const OBJECT_NAME = 'os_production_plan'; +const RECORD_ID = 'rec-plan-1'; + +const OBJECTS = [ + { + name: OBJECT_NAME, + label: 'Production Plan', + fields: { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + }, + }, +]; + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: [] })), + findOne: vi.fn(async () => ({ id: RECORD_ID, name: 'Plan A' })), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + } as any; +} + +const METADATA = { + objects: OBJECTS, + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], +} as any; + +function renderDetail() { + return render( + + + {}} + objectNameOverride={OBJECT_NAME} + recordIdOverride={RECORD_ID} + embedded + /> + + , + ); +} + +/** The record page's OWN provider capture, built against the LOADED record. */ +function recordPageCapture() { + return [...captured] + .reverse() + .find((c) => c.handlers && 'approval' in c.handlers && c.context?.record?.id === RECORD_ID); +} + +/** Render the view and hand back its ActionProvider handlers, spies cleared. */ +async function mountAndCaptureHandlers() { + renderDetail(); + await waitFor(() => expect(recordPageCapture()).toBeTruthy()); + const handlers = recordPageCapture()!.handlers; + // Anything the mount itself fetched is not the dispatch under test. + authFetchSpy.mockClear(); + return handlers; +} + +beforeEach(() => { + cleanup(); + captured.length = 0; + authFetchSpy.mockClear(); + // Unrelated chrome on this view (approvals, favourites, …) reaches for the + // platform API; in jsdom that is a real socket. Answer it locally so the + // only asynchrony left is the record load the capture waits on. + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("RecordDetailView api handler — `{field}` target interpolation (objectui#3391)", () => { + it('interpolates {id} from the page record when no _rowRecord is stashed', async () => { + const handlers = await mountAndCaptureHandlers(); + + let r: any; + await act(async () => { + r = await handlers.api({ + name: 'copy_plan_row', + type: 'api', + method: 'PATCH', + target: `/api/v1/data/${OBJECT_NAME}/{id}`, + params: { copy_ovr_start: '2026-01-01' }, + bodyExtra: { copy_now: true }, + }); + }); + + expect(authFetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = authFetchSpy.mock.calls[0] as unknown as [string, RequestInit]; + expect(url.endsWith(`/api/v1/data/${OBJECT_NAME}/${RECORD_ID}`)).toBe(true); + expect(url).not.toContain('{'); + expect(url).not.toContain('%7B'); + expect(init.method).toBe('PATCH'); + expect(JSON.parse(String(init.body))).toMatchObject({ + copy_ovr_start: '2026-01-01', + copy_now: true, + }); + expect(r.success).toBe(true); + }); + + it('lets a stashed _rowRecord win over the page record and strips it from the body', async () => { + const handlers = await mountAndCaptureHandlers(); + + await act(async () => { + await handlers.api({ + name: 'child_action', + type: 'api', + method: 'POST', + target: `/api/v1/data/${OBJECT_NAME}/{id}`, + params: { _rowRecord: { id: 'child-9' }, note: 'n' }, + }); + }); + + expect(authFetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = authFetchSpy.mock.calls[0] as unknown as [string, RequestInit]; + expect(url.endsWith(`/api/v1/data/${OBJECT_NAME}/child-9`)).toBe(true); + const body = JSON.parse(String(init.body)); + expect(body).toMatchObject({ note: 'n' }); + expect(body).not.toHaveProperty('_rowRecord'); + }); + + it('never interpolates a retargeted action from the parent page record', async () => { + const handlers = await mountAndCaptureHandlers(); + + await act(async () => { + await handlers.api({ + name: 'child_only', + type: 'api', + method: 'POST', + objectName: 'other_object', + target: '/api/v1/data/other_object/{id}', + }); + }); + + expect(authFetchSpy).toHaveBeenCalledTimes(1); + const [url] = authFetchSpy.mock.calls[0] as unknown as [string, RequestInit]; + // The parent record's id must NOT be substituted into a child target — + // the literal token going out (and the server 4xx) is the correct + // fail-loud outcome for a dispatch that lost its row. + expect(url).toContain('{id}'); + expect(url).not.toContain(RECORD_ID); + }); + + it('strips the _rowRecord stash from the flow trigger body', async () => { + const handlers = await mountAndCaptureHandlers(); + + await act(async () => { + await handlers.flow({ + name: 'copy_plan_flow', + type: 'flow', + target: 'copy_plan_flow', + params: { _rowRecord: { id: 'r1' }, comment: 'hi' }, + }); + }); + + expect(authFetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = authFetchSpy.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toContain('/api/v1/automation/copy_plan_flow/trigger'); + const body = JSON.parse(String(init.body)); + expect(body.params).toEqual({ comment: 'hi' }); + expect(body.recordId).toBe(RECORD_ID); + expect(body.objectName).toBe(OBJECT_NAME); + }); +}); diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index 5eb52954bc..bf1518846f 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -620,11 +620,22 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri const targetStr = typeof target === 'string' ? target : ''; if (targetStr.startsWith('/') || /^https?:\/\//i.test(targetStr)) { const baseUrl = import.meta.env.VITE_SERVER_URL || ''; - // Interpolate `{field}` tokens in the target URL from the row record. + // Interpolate `{field}` tokens in the target URL. Source: the stashed + // row when the dispatch carries one (related-list rows; page:header + // since objectui#3391), else THIS page's record — but only when the + // action doesn't retarget another object (a child action interpolated + // from the parent's fields would hit the wrong record; same guard as + // the recordIdParam fallback below). Without the page-record fallback, + // a `record_header` action reaching here through any dispatch path + // that doesn't stash `_rowRecord` sent the literal `{id}` on the wire. + const interpolationRecord = rowRecord + ?? (!action.objectName || action.objectName === objectName + ? (pageRecord as Record | undefined) ?? undefined + : undefined); let resolvedTarget = targetStr; - if (rowRecord && /\{[a-z_][a-z0-9_]*\}/i.test(resolvedTarget)) { + if (interpolationRecord && /\{[a-z_][a-z0-9_]*\}/i.test(resolvedTarget)) { resolvedTarget = resolvedTarget.replace(/\{([a-z_][a-z0-9_]*)\}/gi, (_, k) => { - const v = rowRecord[k]; + const v = interpolationRecord[k]; return v == null ? '' : encodeURIComponent(String(v)); }); } @@ -755,6 +766,15 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri } try { const baseUrl = import.meta.env.VITE_SERVER_URL || ''; + // `_rowRecord` is the client-side row stash (page:header dispatches + // carry it since objectui#3391) — never part of the trigger contract; + // strip it exactly like useConsoleActionRuntime's flowHandler does. + const flowParams = { + ...(action.params && !Array.isArray(action.params) + ? (action.params as Record) + : {}), + }; + delete flowParams._rowRecord; const res = await authFetch( `${baseUrl}/api/v1/automation/${encodeURIComponent(flowName)}/trigger`, { @@ -766,7 +786,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // record when the action carries none (header/more actions). recordId: (action as any).recordId ?? pureRecordId, objectName: action.objectName ?? objectName, - params: action.params ?? {}, + params: flowParams, }), }, ); diff --git a/packages/components/src/__tests__/page-header-actions.test.tsx b/packages/components/src/__tests__/page-header-actions.test.tsx index 7ef99286b0..707f4df03b 100644 --- a/packages/components/src/__tests__/page-header-actions.test.tsx +++ b/packages/components/src/__tests__/page-header-actions.test.tsx @@ -8,7 +8,7 @@ import { describe, it, expect, vi } from 'vitest'; import { useEffect } from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { ComponentRegistry } from '@object-ui/core'; import { ActionProvider, @@ -423,6 +423,125 @@ describe('PageHeaderRenderer — inline/overflow split (objectui#2361)', () => { }); }); +// objectui#3391 — record dispatch shape. A `record_header` / `record_more` +// action must reach the runtime in the SAME shape ObjectGrid row actions and +// DeclaredActionsBar dispatch: the record stashed under `params._rowRecord` +// (the api handler's `{field}` URL-interpolation + record-id source) and a +// spec-shaped `params` ARRAY surfaced as `actionParams`. Before this, a +// `type:'api'` header action targeting `/api/v1/data/:object/{id}` was sent +// with the literal `{id}` (`%7Bid%7D` on the wire) while the same declaration +// interpolated fine from `list_item`. +describe('PageHeaderRenderer — record dispatch shape (objectui#3391)', () => { + function renderWithApiHandler(opts: { + action: any; + record?: any; + onParamCollection?: (params: any[], action?: any) => Promise | null>; + }) { + const api = vi.fn(async () => ({ success: true })); + const header = ; + render( + + {opts.record !== undefined ? ( + + {header} + + ) : ( + header + )} + , + ); + return { api }; + } + + it('stashes the record under params._rowRecord without mutating the authored node', async () => { + const record = { id: 'rec-1', status: 'open' }; + const action = { + name: 'copy_plan_row_3391', + type: 'api', + method: 'PATCH', + locations: ['record_header'], + label: 'Copy Plan', + target: '/api/v1/data/os_plan/{id}', + }; + const { api } = renderWithApiHandler({ action, record }); + fireEvent.click(screen.getByRole('button', { name: /Copy Plan/i })); + await waitFor(() => expect(api).toHaveBeenCalledTimes(1)); + const dispatched: any = api.mock.calls[0][0]; + expect(dispatched.params._rowRecord).toBe(record); + expect(dispatched.target).toBe('/api/v1/data/os_plan/{id}'); + // The authored schema node must stay pristine — the runner merges + // collected params into `action.params` IN PLACE, so dispatching the raw + // node would pollute the schema between invocations. + expect(action).not.toHaveProperty('params'); + }); + + it('surfaces a spec-shaped params ARRAY as actionParams and keeps the stash through collection', async () => { + const record = { id: 'rec-2' }; + const paramDefs = [{ name: 'copy_ovr_start', type: 'date', required: true }]; + const onParamCollection = vi.fn(async () => ({ copy_ovr_start: '2026-01-01' })); + const action = { + name: 'copy_plan_params_3391', + type: 'api', + locations: ['record_header'], + label: 'Copy With Params', + target: '/api/v1/data/os_plan/{id}', + params: paramDefs, + }; + const { api } = renderWithApiHandler({ action, record, onParamCollection }); + fireEvent.click(screen.getByRole('button', { name: /Copy With Params/i })); + await waitFor(() => expect(api).toHaveBeenCalledTimes(1)); + expect(onParamCollection).toHaveBeenCalledTimes(1); + expect(onParamCollection.mock.calls[0][0]).toEqual(paramDefs); + const dispatched: any = api.mock.calls[0][0]; + // The runner merged the collected values into `params` while PRESERVING + // the row stash (ActionRunner's `_rowRecord` carve-out). + expect(dispatched.params.copy_ovr_start).toBe('2026-01-01'); + expect(dispatched.params._rowRecord).toBe(record); + expect(dispatched.actionParams).toEqual(paramDefs); + // Authored node untouched: `params` is still the param-def ARRAY. + expect(action.params).toBe(paramDefs); + }); + + it('merges the stash into object-shaped params without dropping authored keys', async () => { + const record = { id: 'rec-3' }; + const action = { + name: 'object_params_3391', + type: 'api', + locations: ['record_header'], + label: 'Object Params', + target: '/api/v1/data/os_plan/{id}', + params: { channel: 'header' }, + }; + const { api } = renderWithApiHandler({ action, record }); + fireEvent.click(screen.getByRole('button', { name: /Object Params/i })); + await waitFor(() => expect(api).toHaveBeenCalledTimes(1)); + const dispatched: any = api.mock.calls[0][0]; + expect(dispatched.params).toMatchObject({ channel: 'header', _rowRecord: record }); + expect((action.params as any)._rowRecord).toBeUndefined(); + }); + + it('dispatches unchanged outside a record context', async () => { + const action = { + name: 'no_record_3391', + type: 'api', + locations: ['record_header'], + label: 'No Record', + target: '/api/v1/misc/ping', + }; + const { api } = renderWithApiHandler({ action }); + fireEvent.click(screen.getByRole('button', { name: /No Record/i })); + await waitFor(() => expect(api).toHaveBeenCalledTimes(1)); + const dispatched: any = api.mock.calls[0][0]; + expect(dispatched).toBe(action); + expect(dispatched.params).toBeUndefined(); + }); +}); + // #2358 — the three action-visibility traps. NOTE: the diagnostics warn ONCE // per (action name, predicate) pair via a module-level Set, so every test // below uses unique action names/predicates to stay independent. diff --git a/packages/components/src/renderers/layout/containers.tsx b/packages/components/src/renderers/layout/containers.tsx index 7c370f8c0c..4033b3663e 100644 --- a/packages/components/src/renderers/layout/containers.tsx +++ b/packages/components/src/renderers/layout/containers.tsx @@ -1077,6 +1077,35 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { }); }, [rawHeaderActions, hostSystemActions, ctx?.data, predicateScope]); + // Dispatch shape for record-page header actions (objectui#3391) — the same + // shape ObjectGrid row actions, RelatedRecordActionsBridge, and + // DeclaredActionsBar use: stash the current record under `params._rowRecord` + // (the api handler's `{field}` URL-interpolation + record-id source) and + // surface a spec-shaped `params` ARRAY as `actionParams` (the runner's + // param-dialog input), reserving `params` for the stash. Without this, a + // `record_header` `type:'api'` action targeting `/api/v1/data/:object/{id}` + // was sent with the literal `{id}` (`%7Bid%7D` on the wire) while the SAME + // declaration interpolated fine from `list_item`. Dispatching a fresh object + // also keeps ActionRunner's collected-params merge (which writes + // `action.params` in place) from mutating the authored schema node between + // invocations. Non-record hosts (no RecordContext data) dispatch unchanged. + const record = ctx?.data; + const dispatchHeaderAction = React.useCallback((action: any) => { + if (!record || typeof record !== 'object') { + void execute(action); + return; + } + const { params: rawParams, ...rest } = (action ?? {}) as Record; + const dispatch: any = { ...rest }; + if (Array.isArray(rawParams)) { + if (!dispatch.actionParams && rawParams.length > 0) dispatch.actionParams = rawParams; + dispatch.params = { _rowRecord: record }; + } else { + dispatch.params = { ...(rawParams || {}), _rowRecord: record }; + } + void execute(dispatch); + }, [record, execute]); + const renderHeaderActions = () => { if (headerActions.length === 0) return null; // Resolve a translated label for an action via the @@ -1186,7 +1215,7 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { void action.onClick(); return; } - void execute(action); + dispatchHeaderAction(action); }} > {icon && } @@ -1230,7 +1259,7 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { void action.onClick(); return; } - void execute(action); + dispatchHeaderAction(action); }} className={cn( 'gap-2',