Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/quiet-doors-forward-bodyextra.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@object-ui/components': patch
'@object-ui/core': patch
'@object-ui/types': patch
---

fix(actions): forward `bodyExtra` end-to-end through the action chain

An action's static request body (`bodyExtra`) was dropped one hop before the
`ActionRunner`: every action renderer forwards an explicit whitelist of keys, and
none of them listed `bodyExtra`. Since `@objectstack/spec` 17 made it the only way
a `type: 'api'` action can carry a payload (`params` keeps its single meaning as
the parameter definition array), and the ADR-0087
`inline-action-api-params-to-body-extra` conversion rewrites older object-form
`params` pages onto it at load, a previously-working published page validated,
published and then POSTed an empty body.

`element:button`, `action:button`, `action:group`, `action:icon` and `action:menu`
now forward the key; `ActionRunner.executeAPI` merges it into the request body
**last** (so a constant always overrides a same-named user param, matching the
console `apiHandler`); `ActionSchema` declares it; and a non-array `params` on a
`type: 'api'` action keeps working for one version window with a dev-mode
deprecation warning naming `bodyExtra`.
177 changes: 177 additions & 0 deletions packages/components/src/__tests__/action-bodyExtra-forward.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/**
* 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.
*/

/**
* `bodyExtra` survives the renderer hop (objectstack#6837).
*
* Every action renderer forwards an EXPLICIT whitelist of keys to the
* `ActionRunner` — a deliberate list, not a spread, so that a key no renderer
* honours cannot look wired. The cost of that design is that a NEW key is
* invisible until it is added to each list, and `bodyExtra` was the live example:
* `@objectstack/spec` 17 made it the only way a `type: 'api'` action can carry a
* static request payload (#5777 ruling, direction A — `params` keeps its one
* meaning as the parameter DEFINITION array), and the ADR-0087 conversion
* rewrites old object-form `params` pages onto it at load. The whitelists then
* dropped it one hop before the runner, so a previously-working published page
* POSTed an empty body.
*
* These tests assert at the two ends that matter: what the runner RECEIVES
* (the forward itself) and what goes on the WIRE (the forward plus the runner's
* merge order). A test that only checked the props would have passed while the
* payload was still lost downstream.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { ActionProvider } from '@object-ui/react';
import '../renderers/basic/elements';
import '../renderers/action/action-button';
import '../renderers/action/action-menu';

function Registered({ type, schema }: { type: string; schema: any }) {
const C = ComponentRegistry.get(type);
if (!C) throw new Error(`${type} not registered`);
// eslint-disable-next-line react-hooks/static-components -- ComponentRegistry.get returns a registered component (stable), not one created during render
return <C schema={schema} />;
}

describe('bodyExtra survives the renderer forward whitelist', () => {
let api: ReturnType<typeof vi.fn>;

beforeEach(() => {
api = vi.fn(async () => ({ success: true }));
});

/** The ActionDef the runner was handed for the click. */
const dispatched = () => api.mock.calls[0][0];

it('element:button forwards bodyExtra to the runner', async () => {
render(
<ActionProvider handlers={{ api }}>
<Registered
type="element:button"
schema={{
type: 'element:button',
properties: {
label: 'Submit inquiry',
action: {
type: 'api',
target: '/api/v1/forms/contact-us/submit',
method: 'POST',
bodyExtra: { name: '{{page.inquiryName}}', source: 'web' },
},
},
}}
/>
</ActionProvider>,
);

fireEvent.click(screen.getByRole('button', { name: /Submit inquiry/i }));
await waitFor(() => expect(api).toHaveBeenCalledOnce());
expect(dispatched().bodyExtra).toEqual({ name: '{{page.inquiryName}}', source: 'web' });
});

it('action:button forwards bodyExtra off a declared ActionSchema', async () => {
render(
<ActionProvider handlers={{ api }}>
<Registered
type="action:button"
schema={{
type: 'api',
name: 'revoke_key',
label: 'Revoke',
target: '/api/v1/sys_api_key/k1',
method: 'PATCH',
bodyExtra: { revoked: true },
}}
/>
</ActionProvider>,
);

fireEvent.click(screen.getByRole('button', { name: /Revoke/i }));
await waitFor(() => expect(api).toHaveBeenCalledOnce());
expect(dispatched().bodyExtra).toEqual({ revoked: true });
});

it('action:menu forwards bodyExtra for an overflow item', async () => {
render(
<ActionProvider handlers={{ api }}>
<Registered
type="action:menu"
schema={{
type: 'action:menu',
label: 'More',
actions: [
{
type: 'api',
name: 'close_order',
label: 'Close order',
target: '/api/v1/order/close',
bodyExtra: { status: 'closed' },
},
],
}}
/>
</ActionProvider>,
);

// Radix opens its dropdown on pointerdown, not click — same gesture the
// form-widget tests use for a Radix Select trigger.
fireEvent.pointerDown(screen.getByRole('button', { name: /More/i }), { button: 0 });
const item = await screen.findByText(/Close order/i);
fireEvent.click(item);
await waitFor(() => expect(api).toHaveBeenCalledOnce());
expect(dispatched().bodyExtra).toEqual({ status: 'closed' });
});
});

describe('bodyExtra wins over a deprecated object-form params, end to end', () => {
beforeEach(() => {
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({}) });
vi.spyOn(console, 'warn').mockImplementation(() => {});
});

it('renders, clicks, and puts bodyExtra last on the wire', async () => {
// No custom `api` handler here on purpose: the click has to travel the whole
// chain — renderer whitelist, then the runner's own executeAPI body assembly —
// so the merge order is asserted on the actual request body rather than on
// props. `status` is declared in BOTH places with different values, which is
// what makes a reversed merge red instead of a tie.
render(
<ActionProvider>
<Registered
type="element:button"
schema={{
type: 'element:button',
properties: {
label: 'Close order',
action: {
type: 'api',
target: '/api/v1/order/close',
method: 'PATCH',
params: { status: 'draft', note: 'from the user' },
bodyExtra: { status: 'closed', closed_at: '2026-08-09' },
},
},
}}
/>
</ActionProvider>,
);

fireEvent.click(screen.getByRole('button', { name: /Close order/i }));
await waitFor(() => expect(global.fetch as any).toHaveBeenCalledOnce());

const body = JSON.parse((global.fetch as any).mock.calls[0][1].body);
expect(body).toEqual({
status: 'closed', // bodyExtra beat the same-named param
note: 'from the user', // the untouched param still rides along
closed_at: '2026-08-09',
});
});
});
5 changes: 5 additions & 0 deletions packages/components/src/renderers/action/action-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ const ActionButtonRenderer = forwardRef<HTMLButtonElement, ActionButtonProps>(
endpoint: schema.endpoint,
method: schema.method,
...paramsPayload,
// Static request body of a `type: 'api'` action, merged last by the
// runner. Separate key from `params` by the objectstack#5777 ruling —
// dropping it here left a declared action whose payload lives only in
// `bodyExtra` POSTing nothing (objectstack#6837).
bodyExtra: schema.bodyExtra,
confirmText: schema.confirmText,
successMessage: schema.successMessage,
errorMessage: schema.errorMessage,
Expand Down
2 changes: 2 additions & 0 deletions packages/components/src/renderers/action/action-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,8 @@ const ActionGroupRenderer = forwardRef<HTMLDivElement, { schema: ActionGroupSche
endpoint: action.endpoint,
method: action.method,
params: action.params as Record<string, any> | undefined,
// See action-button.tsx — the `type: 'api'` payload key (objectstack#6837).
bodyExtra: action.bodyExtra,
confirmText: action.confirmText,
successMessage: action.successMessage,
errorMessage: action.errorMessage,
Expand Down
2 changes: 2 additions & 0 deletions packages/components/src/renderers/action/action-icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ const ActionIconRenderer = forwardRef<HTMLButtonElement, ActionIconProps>(
endpoint: schema.endpoint,
method: schema.method,
params: schema.params as Record<string, any> | undefined,
// See action-button.tsx — the `type: 'api'` payload key (objectstack#6837).
bodyExtra: schema.bodyExtra,
confirmText: schema.confirmText,
successMessage: schema.successMessage,
errorMessage: schema.errorMessage,
Expand Down
2 changes: 2 additions & 0 deletions packages/components/src/renderers/action/action-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ const ActionMenuRenderer = forwardRef<HTMLButtonElement, { schema: ActionMenuSch
endpoint: action.endpoint,
method: action.method,
params: action.params as Record<string, any> | undefined,
// See action-button.tsx — the `type: 'api'` payload key (objectstack#6837).
bodyExtra: action.bodyExtra,
confirmText: action.confirmText,
successMessage: action.successMessage,
errorMessage: action.errorMessage,
Expand Down
8 changes: 8 additions & 0 deletions packages/components/src/renderers/basic/elements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,14 @@ function ElementButtonRenderer({ schema }: { schema: any }) {
navigate: action.navigate,
to: action.to,
opensInNewTab: action.opensInNewTab,
// The static request body of a `type: 'api'` action. `params` is the
// parameter DEFINITION array and carries no payload (objectstack#5777
// ruling, direction A), so without this forward an inline action's
// payload has no way through: it validates, publishes, and is dropped
// exactly here, one hop before the runner (objectstack#6837). With it,
// this list matches spec's `InlineActionSchema` pick list field for
// field — that pick list is the contract this whitelist mirrors.
bodyExtra: action.bodyExtra,
confirmText: action.confirmText,
successMessage: action.successMessage,
errorMessage: action.errorMessage,
Expand Down
47 changes: 44 additions & 3 deletions packages/core/src/actions/ActionRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import type { ActionInput as SpecActionInput } from '@objectstack/spec/ui';
import { ExpressionEvaluator } from '../evaluator/ExpressionEvaluator';
import { toPredicateInput } from '../evaluator/predicateInput';
import { globalUndoManager, type UndoableOperation } from './UndoManager';
import { warnOnUnknownActionKeys } from './actionKeys';
import { warnOnDeprecatedObjectParams, warnOnUnknownActionKeys } from './actionKeys';

export interface ActionResult {
success: boolean;
Expand Down Expand Up @@ -761,6 +761,14 @@ export class ActionRunner {
// does nothing" shape. Dev-only, warn-once, changes nothing (#4075 step 1).
warnOnUnknownActionKeys(action);

// The compat window for the object-form `params` payload (#5777, maintainer
// ruling 2026-08-06 direction A). Checked HERE, before the param-collection
// block below rewrites `action.params` into a values map — after that point
// an action that authored a legitimate ActionParam[] DEFINITION array also
// carries an object under `params`, and the warning could no longer tell the
// two apart. Dev-only, warn-once, changes nothing.
warnOnDeprecatedObjectParams(action);

// Resolve the action type
const actionType = action.type || action.actionType || action.name || '';

Expand Down Expand Up @@ -1358,6 +1366,39 @@ export class ActionRunner {
return this.navigateTo(action, rawUrl, nav);
}

/**
* Assemble the request body for the runner's OWN `executeAPI` — the path taken
* when no host registered an `api` handler.
*
* `bodyExtra` is merged LAST, which is the spec's documented semantics for the
* key ("Static request-body fields for a `type:'api'` action, merged last
* (overrides user params)") and what the console's `apiHandler` already does on
* both of its branches. Before objectstack#6837 this path read `action.params`
* alone and never looked at `bodyExtra` at all, so an action whose payload lives
* entirely in `bodyExtra` — the shape the ADR-0087 conversion produces from an
* old object-form `params` page — POSTed an empty body here.
*
* `params` contributes only in its DEPRECATED non-array form (the compat window
* #5777 opened; see `warnOnDeprecatedObjectParams`). An ARRAY `params` is a
* parameter DEFINITION list, not a payload: it reaches this method unconsumed
* only when no `paramCollectionHandler` is mounted, and POSTing the definitions
* as the request body was never a payload any endpoint wanted. It now falls
* through to the context record like an absent `params` does.
*/
private buildApiRequestBody(action: ActionDef): unknown {
const asRecord = (value: unknown): Record<string, unknown> | undefined => (
value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: undefined
);
const bodyExtra = asRecord(action.bodyExtra);
const base = asRecord(action.params) ?? this.context.data;
// Nothing to merge: hand back the historical value as-is, so a host passing a
// non-object `context.data` still serializes exactly what it used to.
if (!bodyExtra) return base ?? {};
return { ...asRecord(base), ...bodyExtra };
}

/**
* Execute API action — supports both simple string endpoint and complex ApiConfig.
*/
Expand All @@ -1380,7 +1421,7 @@ export class ActionRunner {
// Simple string endpoint
url = this.interpolateTarget(apiConfig, action);
method = action.method || 'POST';
body = JSON.stringify(action.params || this.context.data || {});
body = JSON.stringify(this.buildApiRequestBody(action));
} else {
// Complex ApiConfig
const config = apiConfig as ApiConfig;
Expand All @@ -1401,7 +1442,7 @@ export class ActionRunner {
? config.body
: JSON.stringify(config.body);
} else if (method !== 'GET' && method !== 'HEAD') {
body = JSON.stringify(action.params || this.context.data || {});
body = JSON.stringify(this.buildApiRequestBody(action));
}
}

Expand Down
Loading
Loading