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
39 changes: 39 additions & 0 deletions .changeset/10290-container-member-params-evaluated.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
'@object-ui/components': patch
'@object-ui/react': minor
---

fix(components): an action container's members evaluate `properties.params` the way a top-level `action:button` does

A member of `action:bar`, `action:group` or `action:menu` never passes through
`SchemaRenderer`, so the evaluation that resolves `"recordId": "${record.id}"`
in a node's `properties.params` did not run for it. On a record page an
`action:bar` member drawn inline handed its `navigate_edit` handler the raw
`${record.id}` text, and a member that the bar placed in its overflow menu, or
an `action:group` / `action:menu` item, did not forward its `properties.params`
at all.

Each container now evaluates its members' `properties` with the evaluator and
scope `SchemaRenderer` uses: `action:bar` where it composes an inline member,
and `action:group` / `action:menu` when they run an item. A container member's
static values therefore reach the handler as a top-level `action:button`'s do,
with one difference: the development-build unevaluated-expression diagnostic
runs in the `SchemaRenderer` memo, so it does not report an unresolvable
template on a container member. An item that carries an `ActionParam[]` input
list forwards both, as `action:button` does. A node-level `params` object is
still not read as values (objectui#10289). The one exception is unchanged from
objectui#10462: an `action:group` / `action:menu` item of `type: 'api'` still
forwards it as the request payload (objectstack#5777 window), unless the item
also carries a `properties.params` bag, which now takes precedence.

`@object-ui/react` exports `useConfigBagEvaluator()` for this. It returns a
function that applies the `SchemaRenderer` memo's `properties` evaluation (the
same per-key rule and the same `record` / `current_user` / `page` scope) to a
config bag rendered without `SchemaRenderer`. It is not a second template
engine. Its plain half, `evaluateConfigBagInScope()`, is exported beside
`SchemaRenderer`. Both are built from the module-scope scope builder and
per-key rule that the memo itself calls.

Declared as a minor (Clause-②, widening): `@object-ui/react` gains two public
exports, `useConfigBagEvaluator` and `evaluateConfigBagInScope`. Nothing is
removed or renamed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/**
* 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#10290: a member of an action CONTAINER (`action:bar`,
* `action:group`, `action:menu`) carries its static execution values in
* `properties.params` (objectui#10289, ruling A), and those values are
* templates evaluated where `properties` are (objectui#7867, ruling A). A
* container member never passes through the `SchemaRenderer` evaluation memo,
* so each container evaluates its members' `properties` through the evaluator
* that memo uses, against the scope that memo builds.
*
* Driven end to end through the real pieces: the real `SchemaRenderer` renders
* the real container, the click or menu selection goes through the real
* `ActionRunner`, and the value asserted is the `params` on the `ActionDef` the
* registered `navigate_edit` handler receives. The row is bound the way a
* record page binds it, through `RecordContextProvider`.
*
* The CONTROL is a top-level `action:button` with the same `properties.params`.
* It goes through the `SchemaRenderer` memo, so it resolved before this change
* too: a red container row beside a green control means the container, never
* an unbound `record` root.
*/

import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
import { render, screen, fireEvent, waitFor, within } 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 imports: `action:bar` resolves its members and its
// overflow menu through the ComponentRegistry at render time, and the light
// `dom` project does not load the components graph. Module scope, not a
// `beforeAll`, per AGENTS.md 测试纪律.
import '../action-bar';
import '../action-button';
import '../action-icon';
import '../action-menu';
import '../action-group';
import { resetStaticParamsWarnings } from '../static-params';

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

/** The authored values: one template leaf, one literal leaf. */
const AUTHORED = { objectName: 'account', recordId: '${record.id}' };

/** What the handler must receive once the template is evaluated. */
const RESOLVED = { objectName: 'account', recordId: 'rec_1' };

/** The overflow trigger's accessible name, with no i18n bundle loaded. */
const MORE = 'More actions';

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

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

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

/** One member action, as an author writes it inside a container's `actions`. */
function member(extra: Record<string, unknown> = {}): Record<string, unknown> {
return { name: 'edit', label: 'Edit', type: 'navigate_edit', ...extra };
}

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

/** Radix opens a dropdown on `pointerdown` and mounts its content in a portal. */
async function selectFromMenu(trigger: HTMLElement, label: string) {
fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' });
const menu = await screen.findByRole('menu');
fireEvent.click(within(menu).getByRole('menuitem', { name: label }));
}

/** How the member is reached once its container is on screen. */
type Reach = (label: string) => Promise<void>;

const clickButton: Reach = async (label) => {
fireEvent.click(screen.getByRole('button', { name: label }));
};

const viaTrigger = (triggerName: string): Reach => async (label) => {
await selectFromMenu(screen.getByRole('button', { name: triggerName }), label);
};

/** Every path a container member reaches the runner by. */
const CONTAINERS: ReadonlyArray<{
path: string;
schema: (m: Record<string, unknown>) => Record<string, unknown>;
reach: Reach;
}> = [
{
path: 'action:bar, inline member',
schema: (m) => ({ type: 'action:bar', actions: [m] }),
reach: clickButton,
},
{
path: 'action:bar, member drawn as an icon (`component: action:icon`)',
schema: (m) => ({ type: 'action:bar', actions: [{ ...m, component: 'action:icon' }] }),
reach: clickButton,
},
{
path: 'action:bar, member placed in a button group (`component: action:group`)',
schema: (m) => ({ type: 'action:bar', actions: [{ ...m, component: 'action:group' }] }),
reach: clickButton,
},
{
path: 'action:bar, member placed in the overflow menu (`component: action:menu`)',
schema: (m) => ({ type: 'action:bar', actions: [{ ...m, component: 'action:menu' }] }),
reach: viaTrigger(MORE),
},
{
path: 'action:bar, member spilled past `maxVisible` into the overflow menu',
schema: (m) => ({
type: 'action:bar',
maxVisible: 1,
mobileMaxVisible: 1,
actions: [{ name: 'first', label: 'First', type: 'navigate_edit' }, m],
}),
reach: viaTrigger(MORE),
},
{
path: 'action:group, inline display',
schema: (m) => ({ type: 'action:group', actions: [m] }),
reach: clickButton,
},
{
path: 'action:group, dropdown display',
schema: (m) => ({ type: 'action:group', display: 'dropdown', label: 'Group', actions: [m] }),
reach: viaTrigger('Group'),
},
{
path: 'action:menu',
schema: (m) => ({ type: 'action:menu', actions: [m] }),
reach: viaTrigger(MORE),
},
];

/** Mount, reach the member, return the `params` the handler received. */
async function paramsReceived(schema: Record<string, unknown>, reach: Reach): Promise<unknown> {
renderOnRecordPage(schema);
await reach('Edit');
await waitFor(() => expect(navigateEdit).toHaveBeenCalledTimes(1));
return (navigateEdit.mock.calls[0][0] as ActionDef).params;
}

describe('objectui#10290 - a container member\'s `properties.params` is evaluated where `properties` are', () => {
it('CONTROL: a top-level `action:button` resolves `${record.id}` through the SchemaRenderer memo', async () => {
const params = await paramsReceived(
{
type: 'action:button',
name: 'edit',
label: 'Edit',
actionType: 'navigate_edit',
properties: { params: AUTHORED },
},
clickButton,
);
expect(params).toEqual(RESOLVED);
});

describe.each(CONTAINERS)('$path', ({ schema, reach }) => {
it('the handler receives `properties.params` with `${record.id}` resolved', async () => {
const params = await paramsReceived(schema(member({ properties: { params: AUTHORED } })), reach);
expect(params).toEqual(RESOLVED);
});

it('a node-level `params` OBJECT is still not a values channel, and says so once (objectui#10289)', async () => {
const params = await paramsReceived(schema(member({ params: AUTHORED })), reach);
expect(params).toBeUndefined();
const lines = warn.mock.calls
.map((c: unknown[]) => String(c[0]))
.filter((m: string) => m.includes('action "edit"') && m.includes('`params`'));
expect(lines).toHaveLength(1);
});
});
});
16 changes: 14 additions & 2 deletions packages/components/src/renderers/action/action-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,12 @@ import React, { forwardRef, useMemo } from 'react';
import { ComponentRegistry } from '@object-ui/core';
import type { UIActionSchema, ActionLocation, ActionComponent } from '@object-ui/types';
import { ACTION_LOCATIONS, actionRendersAt } from '@object-ui/types';
import { useCondition, toPredicateInput, useCapabilityGate } from '@object-ui/react';
import { useCondition, toPredicateInput, useCapabilityGate, useConfigBagEvaluator } from '@object-ui/react';
import { useObjectTranslation } from '@object-ui/i18n';
import { cn } from '../../lib/utils';
import { useIsMobile } from '../../hooks/use-mobile';
import { ButtonGroup } from '../../custom/button-group';
import { withEvaluatedProperties } from './static-params';

function useActionsLabel(): string {
// useObjectTranslation is provider-safe (never throws); no try/catch, which
Expand Down Expand Up @@ -157,6 +158,9 @@ const ActionBarRenderer = forwardRef<HTMLDivElement, { schema: ActionBarSchema;
const isMobile = useIsMobile();
// [ADR-0066 D4 / framework#3923] Shared capability gate — see below.
const mayInvoke = useCapabilityGate();
// The `SchemaRenderer` memo's `properties` evaluation, for the members this
// bar draws itself (objectui#10290) — see `renderMember` below.
const evaluateBag = useConfigBagEvaluator();

// Filter business actions by location and deduplicate by name
const filteredActions = useMemo(() => {
Expand Down Expand Up @@ -327,6 +331,14 @@ const ActionBarRenderer = forwardRef<HTMLDivElement, { schema: ActionBarSchema;
// One inline member, drawn by the renderer `componentType` names. The whole
// action is spread onto that renderer's schema, so it carries its own gates
// and its `autoTrigger` with it.
//
// The member is mounted without `SchemaRenderer`, so nothing else evaluates
// its `properties`: its static values (`properties.params`, objectui#10289)
// would reach the handler as raw `${…}` text. They are evaluated here, once,
// with the memo's evaluator and scope (objectui#10290). An overflow member
// is NOT evaluated here: it goes to the `action:menu` above as authored,
// and that renderer evaluates it when it runs it, so no value is evaluated
// twice.
const renderMember = (action: UIActionSchema, componentType: ActionComponent) => {
const Renderer = ComponentRegistry.get(componentType);
if (!Renderer) return null;
Expand All @@ -335,7 +347,7 @@ const ActionBarRenderer = forwardRef<HTMLDivElement, { schema: ActionBarSchema;
<Renderer
key={action.name}
schema={{
...action,
...withEvaluatedProperties(action, evaluateBag),
type: componentType,
actionType: action.type,
variant: action.variant || schema.variant,
Expand Down
24 changes: 19 additions & 5 deletions packages/components/src/renderers/action/action-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import type { ActionDef } from '@object-ui/core';
import type { UIActionSchema, ActionLocation } from '@object-ui/types';
import { actionRendersAt } from '@object-ui/types';
import { useAction } from '@object-ui/react';
import { useCondition, toPredicateInput, usePredicateRecordContext } from '@object-ui/react';
import { useCondition, toPredicateInput, usePredicateRecordContext, useConfigBagEvaluator } from '@object-ui/react';
import { Button } from '../../ui';
import {
DropdownMenu,
Expand All @@ -35,7 +35,7 @@ import { cn } from '../../lib/utils';
import { Loader2, ChevronDown } from 'lucide-react';
import { resolveIcon } from './resolve-icon';
import { hasDeclaredVisibilityGate } from './visibility-gate';
import { readActionEntryParamValues } from './static-params';
import { readActionEntryParamValues, readMemberStaticParamValues } from './static-params';

export interface ActionGroupSchema {
type: 'action:group';
Expand Down Expand Up @@ -227,6 +227,9 @@ const ActionGroupRenderer = forwardRef<HTMLDivElement, { schema: ActionGroupSche
} = props;

const { execute } = useAction();
// The `SchemaRenderer` memo's `properties` evaluation, for the member this
// renderer runs itself (objectui#10290) — see `handleExecute`.
const evaluateBag = useConfigBagEvaluator();
const [dropdownLoading, setDropdownLoading] = useState(false);

// The row bound the three canonical ways — see `usePredicateRecordContext`.
Expand Down Expand Up @@ -261,9 +264,20 @@ const ActionGroupRenderer = forwardRef<HTMLDivElement, { schema: ActionGroupSche
// an array is forwarded as `actionParams`, as `action:button` does. An
// object is forwarded as values only for `type: 'api'`, the objectstack#5777
// payload window; any other type drops it (objectui#10462).
//
// The member's static values ride `properties.params`, as on
// `action:button`, and are evaluated here with the `SchemaRenderer` memo's
// evaluator and scope: the member never passes through that memo
// (objectui#10290). Independent of the input list, so both are forwarded.
// They win over the `api` window's object `params`, as `properties.params`
// wins over a node-level object on `action:button`.
const staticValues = readMemberStaticParamValues(action, evaluateBag);
const entryValues = Array.isArray(action.params)
? undefined
: readActionEntryParamValues(action, action.type, 'action:group');
const paramsPayload: ActionDef = Array.isArray(action.params)
? { actionParams: action.params as any }
: { params: readActionEntryParamValues(action, action.type, 'action:group') };
? { actionParams: action.params as any, params: staticValues }
: { params: staticValues !== undefined ? staticValues : entryValues };
await execute({
type: action.type,
name: action.name,
Expand Down Expand Up @@ -309,7 +323,7 @@ const ActionGroupRenderer = forwardRef<HTMLDivElement, { schema: ActionGroupSche
objectName: (action as any).objectName,
});
},
[execute],
[execute, evaluateBag],
);

// Dropdown items share the trigger's loading spinner, so wrap execution to
Expand Down
Loading
Loading