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
32 changes: 32 additions & 0 deletions .changeset/11638-member-properties-params-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@object-ui/components': minor
---

`action:group` and `action:menu` no longer read a member's `properties.params` (objectui#11638). A container member's `properties.params` no longer reaches the action runner.

**Why.** `@objectstack/spec` refuses a `properties` key on an `action:group` / `action:menu` member, with this prescription: "A member carries no `properties` bag: its static parameter values (`properties.params`) are not part of the inline action vocabulary. For a `type: 'api'` member's request body write `bodyExtra`; to run an action with static parameter values, author it as its own `action:button` node, whose `params` object carries them." The census behind that ruling found no writer of the key. The renderers kept a read the spec refuses, so the read is retired.

**Behaviour change**, shipped as `minor` per this repository's version policy:

- A member's `properties.params` is not forwarded as the runner's `params`, and it is no longer template-evaluated. This holds for an `action:group` member (inline and dropdown), an `action:menu` item, and an `action:bar` member that lands in the overflow menu.
- A member whose `params` is an array forwards it as `actionParams` alone. The runner's `params` then carries only what the user answers in the parameter dialog.
- A `type: 'api'` member's object `params` is its request payload (the objectstack#5777 window), and a `properties.params` beside it no longer replaces that payload.
- Unchanged: an `action:button` / `action:icon` node reads its static values from `properties.params` as before, and so does an `action:bar` member drawn inline, which the bar mounts on one of those two renderers.

**FROM** a container member carrying static values:

```json
{ "type": "action:group", "actions": [
{ "name": "edit", "label": "Edit", "type": "navigate_edit", "properties": { "params": { "recordId": "${record.id}" } } }
] }
```

**TO** its own `action:button` node:

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

For a `type: 'api'` member's request body, write `bodyExtra` on the member.

**Clause-②: no.** No export, prop, type member or i18n key is added or removed. The retired helper lived in a module that `@object-ui/components` does not re-export from its entry.
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,46 @@
*/

/**
* 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.
* objectui#10290, as narrowed by objectui#11638.
*
* objectui#10290 made every action CONTAINER (`action:bar`, `action:group`,
* `action:menu`) evaluate a member's `properties.params` where `properties`
* are (objectui#7867, ruling A), because a container member never passes
* through the `SchemaRenderer` evaluation memo. objectui#11638 retired the
* MEMBER half of that: the spec refuses a `properties` bag on an
* `action:group` / `action:menu` member ("A member carries no `properties`
* bag: its static parameter values (`properties.params`) are not part of the
* inline action vocabulary"), and an action that needs static values is its
* own `action:button` node. So the paths split by WHICH reader the member
* reaches:
*
* - NODE path: `action:bar` mounts an inline member on `action:button` /
* `action:icon`, whose own node reader (`readStaticParamValues`) reads
* `properties.params`. The bar still evaluates it first (objectui#10290);
* those pins are unchanged.
* - MEMBER path: `action:group` / `action:menu` run the member themselves,
* and so does an `action:bar` member that lands in the overflow menu. A
* member's `properties.params` reaches the runner on none of them
* (objectui#11638). These are the objectui#10290 pins INVERTED: each used
* to assert the resolved values arrive.
*
* 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`.
* `ActionRunner`, and the value asserted is the `ActionDef` the registered
* 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.
* It goes through the `SchemaRenderer` memo, so it resolves on every path: a
* member row that differs from 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 { ActionContext, ActionDef, ActionResult, ParamCollectionHandler } from '@object-ui/core';
// These nodes are written the way the runtime reads them, flat on the node,
// which the closed `action:*` node types refuse: measured on objectui#11466,
// typing the fixtures as `DeclaredNode` refuses them line by line. So each
Expand All @@ -57,15 +72,30 @@ const AUTHORED = { objectName: 'account', recordId: '${record.id}' };
/** What the handler must receive once the template is evaluated. */
const RESOLVED = { objectName: 'account', recordId: 'rec_1' };

/** An `api` member's object `params`: its request payload (objectstack#5777 window). */
const PAYLOAD = { objectName: 'account', recordId: 'rec_7' };

/** An `ActionParam[]` input list, and what the user answers it with. */
const INPUTS = [{ name: 'reason', type: 'text', label: 'Reason' }];
const COLLECTED = { reason: 'because' };

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

let navigateEdit: Mock<(action: ActionDef, ctx: ActionContext) => Promise<ActionResult>>;
type Handler = Mock<(action: ActionDef, ctx: ActionContext) => Promise<ActionResult>>;

let navigateEdit: Handler;
let api: Handler;
let onParamCollection: Mock<ParamCollectionHandler>;
let warn: ReturnType<typeof vi.spyOn>;
let consoleError: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
navigateEdit = vi.fn(async () => ({ success: true }));
api = vi.fn(async () => ({ success: true }));
// The user fills the one input in; the handler then receives what was
// collected, merged over whatever static `params` the container forwarded.
onParamCollection = vi.fn(async () => COLLECTED);
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
resetStaticParamsWarnings();
Expand All @@ -84,7 +114,7 @@ function member(extra: Record<string, unknown> = {}): Record<string, unknown> {
/** Render `schema` on a record page bound to {@link ROW}. */
function renderOnRecordPage(schema: Record<string, unknown>) {
return render(
<ActionProvider handlers={{ navigate_edit: navigateEdit }}>
<ActionProvider handlers={{ navigate_edit: navigateEdit, api }} onParamCollection={onParamCollection}>
<RecordContextProvider objectName="account" recordId={ROW.id} data={ROW}>
<SchemaRenderer schema={undeclaredNode(schema)} />
</RecordContextProvider>
Expand All @@ -110,12 +140,17 @@ 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<{
type Container = {
path: string;
schema: (m: Record<string, unknown>) => Record<string, unknown>;
reach: Reach;
}> = [
};

/**
* NODE path: `action:bar` mounts the member on `action:button` /
* `action:icon`, whose node reader reads `properties.params`.
*/
const NODE_PATH: ReadonlyArray<Container> = [
{
path: 'action:bar, inline member',
schema: (m) => ({ type: 'action:bar', actions: [m] }),
Expand All @@ -131,6 +166,13 @@ const CONTAINERS: ReadonlyArray<{
schema: (m) => ({ type: 'action:bar', actions: [{ ...m, component: 'action:group' }] }),
reach: clickButton,
},
];

/**
* MEMBER path: the container runs the member itself (`action:group`,
* `action:menu`, and the `action:menu` an `action:bar` overflows into).
*/
const MEMBER_PATH: ReadonlyArray<Container> = [
{
path: 'action:bar, member placed in the overflow menu (`component: action:menu`)',
schema: (m) => ({ type: 'action:bar', actions: [{ ...m, component: 'action:menu' }] }),
Expand Down Expand Up @@ -163,15 +205,27 @@ const CONTAINERS: ReadonlyArray<{
},
];

/** Mount, reach the member, return the `params` the handler received. */
async function paramsReceived(schema: Record<string, unknown>, reach: Reach): Promise<unknown> {
/** Every path a container member reaches the runner by. */
const CONTAINERS: ReadonlyArray<Container> = [...NODE_PATH, ...MEMBER_PATH];

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

describe('objectui#10290 - a container member\'s `properties.params` is evaluated where `properties` are', () => {
/** Mount, reach the member, return the `params` the handler received. */
async function paramsReceived(schema: Record<string, unknown>, reach: Reach): Promise<unknown> {
return (await defReceived(schema, reach)).params;
}

describe('objectui#10290 - an `action:bar` 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(
{
Expand All @@ -186,12 +240,15 @@ describe('objectui#10290 - a container member\'s `properties.params` is evaluate
expect(params).toEqual(RESOLVED);
});

describe.each(CONTAINERS)('$path', ({ schema, reach }) => {
describe.each(NODE_PATH)('$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);
});
});

// On every path, node and member alike.
describe.each(CONTAINERS)('$path', ({ schema, reach }) => {
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();
Expand All @@ -202,3 +259,36 @@ describe('objectui#10290 - a container member\'s `properties.params` is evaluate
});
});
});

describe('objectui#11638 - a container member\'s `properties.params` does not reach the runner', () => {
describe.each(MEMBER_PATH)('$path', ({ schema, reach }) => {
// INVERTED from objectui#10290's "the handler receives `properties.params`
// with `${record.id}` resolved" on this path.
it('a member\'s `properties.params` is not forwarded as static values', async () => {
const params = await paramsReceived(schema(member({ properties: { params: AUTHORED } })), reach);
expect(params).toBeUndefined();
});

it('an array `params` reaches the runner as `actionParams` alone, beside a `properties.params`', async () => {
const def = await defReceived(
schema(member({ params: INPUTS, properties: { params: AUTHORED } })),
reach,
);
expect(onParamCollection).toHaveBeenCalledTimes(1);
expect(onParamCollection.mock.calls[0][0]).toEqual(INPUTS);
expect(def.actionParams).toEqual(INPUTS);
// Only what the user answered: no static values were forwarded to merge
// the answer over.
expect(def.params).toEqual(COLLECTED);
});

it('an `api` member\'s object `params` is its payload, and a `properties.params` beside it no longer replaces it', async () => {
const def = await defReceived(
schema(member({ type: 'api', target: '/api/v1/ping', params: PAYLOAD, properties: { params: AUTHORED } })),
reach,
api,
);
expect(def.params).toEqual(PAYLOAD);
});
});
});
4 changes: 2 additions & 2 deletions packages/components/src/renderers/action/action-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -306,8 +306,8 @@ const ActionBarRenderer = forwardRef<HTMLDivElement, { schema: ActionBarSchema;
// 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.
// and that renderer reads no member's `properties`, so an overflow
// member's `properties.params` does not reach the runner (objectui#11638).
const renderMember = (action: UIActionSchema, componentType: ActionComponent) => {
const Renderer = ComponentRegistry.get(componentType);
if (!Renderer) return null;
Expand Down
28 changes: 10 additions & 18 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 { ACTION_LOCATIONS, actionRendersAt } from '@object-ui/types';
import { useAction } from '@object-ui/react';
import { useCondition, toPredicateInput, usePredicateRecordContext, useConfigBagEvaluator } from '@object-ui/react';
import { useCondition, toPredicateInput, usePredicateRecordContext } 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, readMemberStaticParamValues } from './static-params';
import { readActionEntryParamValues } from './static-params';

// No group-level `name` (objectui#11168): nothing renders, forwards or keys on
// it, and `@objectstack/spec` refuses it on this block, so the registration
Expand Down Expand Up @@ -276,9 +276,6 @@ 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 @@ -320,19 +317,14 @@ const ActionGroupRenderer = forwardRef<HTMLDivElement, { schema: ActionGroupSche
// 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');
// A member has no other source of static values. It carries no
// `properties` bag, and its static parameter values are not part of the
// inline action vocabulary: an action that needs them is its own
// `action:button` node, whose `params` object carries them (the spec's
// member prescription, objectui#11638).
const paramsPayload: ActionDef = Array.isArray(action.params)
? { actionParams: action.params as any, params: staticValues }
: { params: staticValues !== undefined ? staticValues : entryValues };
? { actionParams: action.params as any }
: { params: readActionEntryParamValues(action, action.type, 'action:group') };
await execute({
type: action.type,
name: action.name,
Expand Down Expand Up @@ -381,7 +373,7 @@ const ActionGroupRenderer = forwardRef<HTMLDivElement, { schema: ActionGroupSche
objectName: (action as any).objectName,
});
},
[execute, evaluateBag],
[execute],
);

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