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
37 changes: 37 additions & 0 deletions .changeset/7867-action-params-templates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'@object-ui/react': minor
---

feat(react): an action's `params` values are templates, evaluated where `properties` are

Every string leaf of a node's `params` bag - the node-level `params` and
`properties.params` alike (and the legacy `props.params`, which follows its
canonical bag), at any depth, inside nested objects and arrays - is now
template-evaluated by `SchemaRenderer`, with the same evaluator and the same
scope that already evaluates `properties`. A metadata-authored button on a
record page can therefore name the record it sits on:

```json
{ "type": "action:button", "label": "Edit", "actionType": "navigate_edit",
"params": { "objectName": "account", "recordId": "${record.id}" } }
```

Before this, both spellings of `params.recordId` reached the action handler as
the raw `${record.id}` text: the node-level bag was never evaluated, and
`properties.params` was one value of a shallow loop, so the template inside it
was never visited.

Behaviour change, stated plainly: a `params` string containing `${...}` is now
evaluated at render time instead of being passed through verbatim. It reaches
only values whose templates never worked - no authored `params` bag in this
repository carries one. A template that still cannot be evaluated (an unbound
root such as `${nope.id}`) keeps its source text exactly as before, and the
development-build unevaluated-expression diagnostic now reports it by its path
(`params.recordId`, `properties.params.target.id`), so a wrong template stays
loud.

What is not walked: an ARRAY `params` (the `ActionParam[]` definition list the
params dialog renders) is left as authored; keys are never evaluated, only
values; non-plain objects (`Date`, `Map`, class instances) and functions pass
through by identity; a cycle in a host-built bag ends the walk. Every other
nested config value keeps the shallow reading it had.
25 changes: 15 additions & 10 deletions content/docs/guide/record-edit-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,10 @@ it. Arguments go in a top-level `params` object:
}
```

`navigate_edit` additionally needs the record to open. `params` reaches
the handler verbatim: template expressions such as `${record.id}` are not
evaluated inside `params`, and `action:button` does not inject the
surrounding row, so a declared `navigate_edit` button carries a literal
`recordId`:
`navigate_edit` additionally needs the record to open. `params` values
are templates: every string inside `params`, at any depth, is evaluated
the same way `properties` values are, so a button on a record page names
the record it sits on with `${record.id}`:

```jsonc
{
Expand All @@ -92,15 +91,21 @@ surrounding row, so a declared `navigate_edit` button carries a literal
"actionType": "navigate_edit",
"params": {
"objectName": "account",
"recordId": "0015e000abcd"
"recordId": "${record.id}"
}
}
```

For a per-row **Edit** that follows the record under the cursor, use the
list or detail view's built-in **Edit** entry point instead: under
`editMode: "page"` it already routes to the same URL (see *Migrating an
existing object* below).
`record` is the record the page is bound to. A template that cannot be
evaluated (on a page with no bound record, or with a misspelled root
such as `${recrod.id}`) reaches the handler as its raw `${…}` text, and
the development console reports it under `params.recordId`, so a wrong
template stays visible. A misspelled field on a bound record
(`${record.idd}`) is not an error: it resolves to nothing.

For a per-row **Edit** in a list, use the list view's built-in **Edit**
entry point: under `editMode: "page"` it already routes to the same URL
(see *Migrating an existing object* below).

When invoked from inside an `ObjectView`, the action context already
carries the active `objectName`, so `params` may be omitted entirely:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* 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.
*/

/**
* objectui#7867 (ruling A, maintainer 「其他同意」 2026-09-20): an action's
* `params` values are templates, evaluated where `properties` are.
*
* Every string leaf of a node's `params` bag - the node-level `params` and
* `properties.params` alike, at any depth - is template-evaluated in the
* `SchemaRenderer` evaluation memo. Before this, CASE A and CASE B below reached
* the handler as the raw `${record.id}` text, so a metadata-authored
* `navigate_edit` button could not name the record it sits on.
*
* Driven end to end through the REAL pieces, which is why this pin lives in
* `@object-ui/components` rather than beside `SchemaRenderer` (that package
* deliberately does not depend on this one): the real `SchemaRenderer` renders
* the real `action:button`, the click goes through the real `ActionRunner`, and
* the value asserted is read off the `ActionDef` the registered handler was
* handed - the same seat `AppContent` registers `navigate_edit` in. The row is
* bound the way a record page binds it, through `RecordContextProvider`.
*
* The CONTROL (`properties.label`) is the lit instrument: it resolved before
* this change as well, so a red CASE with a green CONTROL can only mean the
* `params` leg, never an unbound `record` root.
*/

import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import type { ActionContext, ActionDef, ActionResult } from '@object-ui/core';
import type { BaseSchema } from '@object-ui/types';
import { ActionProvider, RecordContextProvider, SchemaRenderer } from '@object-ui/react';
// Module-scope side-effect import so `action:button` is registered before the
// first render - the light `dom` project does not load the components graph.
// Module scope, not a `beforeAll`, per AGENTS.md 测试纪律.
import '../action-button';

const ROW = { id: 'rec_1', name: 'Acme' };

let navigateEdit: Mock<(action: ActionDef, ctx: ActionContext) => Promise<ActionResult>>;
let consoleError: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
navigateEdit = vi.fn(async () => ({ success: true }));
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
consoleError.mockRestore();
});

/** Render `schema` on a record page bound to {@link ROW}. */
function renderOnRecordPage(schema: BaseSchema) {
return render(
<ActionProvider handlers={{ navigate_edit: navigateEdit }}>
<RecordContextProvider objectName="account" recordId={ROW.id} data={ROW}>
<SchemaRenderer schema={schema} />
</RecordContextProvider>
</ActionProvider>,
);
}

/** Click the one button and return the `params` the handler received. */
async function paramsReceived(label: string): Promise<Record<string, any>> {
fireEvent.click(screen.getByRole('button', { name: label }));
await waitFor(() => expect(navigateEdit).toHaveBeenCalledTimes(1));
const def = navigateEdit.mock.calls[0][0] as ActionDef;
return def.params as Record<string, any>;
}

describe('objectui#7867 - `params` values are templates, evaluated where `properties` are', () => {
it('CONTROL: `properties.label` still resolves against the bound record', () => {
renderOnRecordPage({
type: 'action:button',
actionType: 'navigate_edit',
properties: { label: 'L-${record.id}' },
});
expect(screen.getByRole('button', { name: 'L-rec_1' })).toBeInTheDocument();
});

it('CASE A: node-level `params.recordId` resolves to the bound record id', async () => {
renderOnRecordPage({
type: 'action:button',
label: 'Edit',
actionType: 'navigate_edit',
params: { objectName: 'account', recordId: '${record.id}' },
});
const params = await paramsReceived('Edit');
expect(params.recordId).toBe('rec_1');
// A literal leaf is handed over untouched.
expect(params.objectName).toBe('account');
});

it('CASE B: `properties.params.recordId` resolves to the bound record id', async () => {
renderOnRecordPage({
type: 'action:button',
label: 'Edit',
actionType: 'navigate_edit',
properties: { params: { objectName: 'account', recordId: '${record.id}' } },
});
const params = await paramsReceived('Edit');
expect(params.recordId).toBe('rec_1');
expect(params.objectName).toBe('account');
});

it('a template two levels down resolves, inside objects and inside arrays', async () => {
renderOnRecordPage({
type: 'action:button',
label: 'Edit',
actionType: 'navigate_edit',
params: {
target: { record: { id: '${record.id}' } },
ids: ['${record.id}', 'literal'],
caption: 'Edit ${record.name}',
},
});
const params = await paramsReceived('Edit');
expect(params.target.record.id).toBe('rec_1');
expect(params.ids).toEqual(['rec_1', 'literal']);
expect(params.caption).toBe('Edit Acme');
});

it('an unresolvable template stays loud: raw at the handler, and reported by the dev diagnostic', async () => {
renderOnRecordPage({
type: 'action:button',
label: 'Edit',
actionType: 'navigate_edit',
params: { recordId: '${nope.id}' },
});
const params = await paramsReceived('Edit');
// The evaluator hands an expression that throws back as its own source, so
// the verdict an author can see is unchanged: the raw text.
expect(params.recordId).toBe('${nope.id}');
// ... and the unevaluated-expression diagnostic names the leaf by its path.
const reports = consoleError.mock.calls
.map((c: unknown[]) => String(c[0]))
.filter((m: string) => m.includes('params.recordId'));
expect(reports).toHaveLength(1);
expect(reports[0]).toContain('${nope.id}');
});
});
71 changes: 67 additions & 4 deletions packages/react/src/SchemaRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { usePredicateScope } from './hooks/useExpression.js';
import { usePageVariables } from './hooks/usePageVariables.js';
import { resolveKeyedI18nLabel } from './utils/i18n.js';
import { isConfigBag } from './utils/configBag.js';
import { PARAMS_KEY, isParamsBag, mapParamsLeaves } from './utils/paramsBag.js';
import { reportUnevaluatedExpressions } from './utils/unevaluatedExpression.js';
import { reportDroppedPropsBag, reportRefusedPropsPredicate } from './utils/propsBagDiagnostic.js';
import { reportRefusedDataPropSpread } from './utils/refusedDataPropDiagnostic.js';
Expand Down Expand Up @@ -1175,6 +1176,60 @@ export const SchemaRenderer: ForwardRefExoticComponent<
return verdict;
};

/**
* Evaluate ONE config value by its key — THE place the `params` rule is
* stated (objectui#7867, ruling A, maintainer 「其他同意」 2026-09-20):
* *an action's `params` values are templates, evaluated where `properties`
* are.*
*
* A `params` BAG (a plain object — {@link isParamsBag}) has every string
* leaf evaluated at any depth, through the same `evaluator` and so against
* the same scope as every other value in this memo; every other value
* evaluates exactly as it did (per-value and shallow, with a CEL predicate
* envelope preserved — {@link preservePredicateEnvelope}). The walk, and
* what it will not look inside (non-plain objects, keys, cycles, an ARRAY
* `params`, which is the `ActionParam[]` definition list), are defined once
* in `utils/paramsBag.ts`, which the unevaluated-expression diagnostic reads
* too — so what is evaluated and what is reported as left unresolved are
* one radius, not two.
*
* Three carriers, one rule — the ruling names two of them and the third
* follows its canonical bag, as every per-key rule in these loops does:
*
* 1. node-level `params`, immediately below;
* 2. `properties.params`, in the `properties` loop, BEFORE the hoist —
* so the object the hoist copies onto the node (and the renderer
* reads as `schema.params`) is the evaluated one, and
* `schema.properties.params` agrees with it;
* 3. `props.params`, in the legacy-alias loop — the two bag loops must
* not disagree about a key (objectui#5123, objectui#9100).
*
* Each authored value is evaluated exactly once: the node-level leg runs
* before the hoist, so when `properties.params` wins the hoist it replaces
* an evaluated node-level bag with an evaluated bag of its own, and nothing
* already evaluated is evaluated again.
*
* Before this, the node-level bag was never evaluated at all, and
* `properties.params` was ONE value of a shallow loop: measured through the
* real `SchemaRenderer` -> `action:button` -> runner on a record page bound
* to `{ id: 'rec_1' }`, both `params.recordId: '${record.id}'` spellings
* reached the handler raw while `properties.label: 'L-${record.id}'`
* rendered `L-rec_1`. A template that still cannot resolve keeps its source
* text (that is the evaluator's own contract) and is reported by the
* unevaluated-expression diagnostic, so a wrong template stays loud.
*/
const evaluateConfigValue = (key: string, value: unknown): unknown =>
key === PARAMS_KEY && isParamsBag(value)
? mapParamsLeaves(value, (leaf) => evaluator.evaluate(leaf))
: preservePredicateEnvelope(key, value, (v) => evaluator.evaluate(v as any));

// Carrier 1 of the `params` rule above: the node-level bag. A non-bag
// node-level `params` (the `ActionParam[]` definition list, or anything
// degenerate) is left exactly as authored, as it always was.
if (isParamsBag(newSchema[PARAMS_KEY])) {
newSchema[PARAMS_KEY] = evaluateConfigValue(PARAMS_KEY, newSchema[PARAMS_KEY]);
}

// Evaluate 'properties' — the SPEC spelling of a node's config bag, of
// which `props` (evaluated below) is the legacy alias.
//
Expand Down Expand Up @@ -1204,6 +1259,11 @@ export const SchemaRenderer: ForwardRefExoticComponent<
// source today). Deepening that is a separate decision and would have to be
// taken for both spellings at once — not smuggled in on one side here.
//
// ONE key is deep, by that separate decision, and on both spellings at once:
// `params` (objectui#7867, ruling A) — every string leaf of a `params` bag
// is evaluated, through {@link evaluateConfigValue} above. Every other key
// keeps the shallow reading this paragraph describes.
//
// Guarded by {@link isConfigBag}: a degenerate value must not have its shape
// reinterpreted by an object spread. Non-objects skip evaluation and reach
// the hoist — which, since objectui#6760, refuses them on its own.
Expand Down Expand Up @@ -1243,8 +1303,10 @@ export const SchemaRenderer: ForwardRefExoticComponent<
const newProperties: Record<string, any> = { ...rawPropertiesBag };
for (const [key, val] of Object.entries(newProperties)) {
// objectui#9100 — a CEL predicate envelope survives this loop; see
// `preservePredicateEnvelope`. Every other value evaluates as before.
newProperties[key] = preservePredicateEnvelope(key, val, (v) => evaluator.evaluate(v as any));
// `preservePredicateEnvelope`. objectui#7867 — a `params` bag has every
// string leaf evaluated (carrier 2 of `evaluateConfigValue`). Every
// other value evaluates as before.
newProperties[key] = evaluateConfigValue(key, val);
}
newSchema.properties = newProperties;
}
Expand Down Expand Up @@ -1473,8 +1535,9 @@ export const SchemaRenderer: ForwardRefExoticComponent<
const newProps = { ...newSchema.props };
for (const [key, val] of Object.entries(newProps)) {
// objectui#9100, same guard as the `properties` branch above — the two
// channels must not disagree about whether a `cel` envelope survives.
newProps[key] = preservePredicateEnvelope(key, val, (v) => evaluator.evaluate(v as any));
// channels must not disagree about whether a `cel` envelope survives,
// nor (objectui#7867, carrier 3) about how deep a `params` bag goes.
newProps[key] = evaluateConfigValue(key, val);
}
newSchema.props = newProps;
}
Expand Down
Loading
Loading