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',