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
16 changes: 16 additions & 0 deletions .changeset/13022-action-row-declared-patch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/core': patch
'@object-ui/components': patch
'@object-ui/app-shell': patch
---

The Undo of an `operation: 'update'` action covers the `patch` of the declared action its `name` names, and no action renderer forwards a page action row's own `patch` (objectui#13022, following `@objectstack/spec` 18 retiring that row key, objectstack#24442).

A page action row (`action:button`, `action:icon`, an `action:group` / `action:menu` member) with `operation: 'update'` runs the declared action its `name` names. The platform action route resolves that declaration by name and writes its `patch` under the collected params. The row's own `patch` reached no request since objectui#12941, and the runner read it only for the Undo snapshot, so the snapshot covered the wrong fields. A row with no `patch` of its own snapshotted the collected params alone, not the fields the route wrote.

- **Undo restores the fields the route writes.** `ActionRunner` reads the `patch` of the declaration the action's `name` names from the `objectActions` its host publishes beside `objectName`, plus the collected params. A declaration with no `patch` keeps the params-only snapshot. A written field the row does not carry still offers no Undo.
- **The console publishes the declarations.** `useConsoleActionRuntime` and the record page publish the object's `actions` as `objectActions` in the runner context, beside `objectFields`.
- **A host that publishes none** is unchanged for an action it dispatches whole (a grid row, a declared-actions bar, a related-list row): the runner reads that def's own `patch`, which is the declaration's.
- **`action:button`, `action:icon`, `action:group` and `action:menu` no longer forward `patch`.** The request body is unchanged: it carries the collected params alone, as since objectui#12941.

The `patch` input `action:button` and `action:icon` publish is unchanged here. It follows the spec pin.
15 changes: 10 additions & 5 deletions content/docs/guide/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -681,16 +681,21 @@ The rest of the action is authored on the same node. `action:button` and `action
evaluate `visible` and `disabled` themselves (a boolean, a CEL string, or a
`{ dialect, source }` envelope), and forward the action-definition keys to the runner:
`params`, `confirmText`, `successMessage`, `errorMessage`, `toast`, `bodyExtra`,
`bodyShape`, `operation` / `patch`, `onSuccess`, `objectName` and the others each
`bodyShape`, `operation`, `onSuccess`, `objectName` and the others each
registration's `inputs` lists, with a `description` per key. For an `api` action, write
the request URL in `target`: `endpoint` is not published, because the console's `api`
handler never reads it.

An `operation: update` node runs the declared action its `name` names: the platform
action route writes that declaration's `patch` under the collected params. A `patch`
written on the node itself is not forwarded and writes nothing.

`undoable: true` on an `operation: update` makes the success toast offer Undo, which
writes back the prior values of the fields the update wrote. `action:button` reads those
values off the record in scope: the record page's record, or the row the host binds
through `data`. A button with no record in scope offers no Undo, because there is no row
to restore; neither does one whose `recordId` names a record other than the one in scope.
writes back the prior values of the fields the update wrote: the named declaration's
`patch` fields and the collected params. `action:button` reads those values off the
record in scope: the record page's record, or the row the host binds through `data`. A
button with no record in scope offers no Undo, because there is no row to restore;
neither does one whose `recordId` names a record other than the one in scope.

## Responsive Behavior

Expand Down
4 changes: 4 additions & 0 deletions packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,10 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
// Undo capture reads them to tell a relation, captured as its stored id,
// from a field whose value merely has an object's shape.
...(objectName && objectDef?.fields ? { objectFields: objectDef.fields } : {}),
// Its declared actions, beside them (objectui#13022): the Undo capture
// of an `operation: 'update'` reads the `patch` of the declaration the
// action's `name` names, the one the platform action route writes.
...(objectName && Array.isArray(objectDef?.actions) ? { objectActions: objectDef.actions } : {}),
user: currentUser,
// Backend origin — lets `type: 'url'` actions issue full-page
// navigations to API endpoints across origins in dev.
Expand Down
4 changes: 4 additions & 0 deletions packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3676,6 +3676,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
// (objectui#11122): the runner's `operation: 'update'` Undo capture
// reads them to capture a relation `$expand` filled as its stored id.
...(objectDef?.fields ? { objectFields: objectDef.fields } : {}),
// Its declared actions (objectui#13022): the same capture reads the
// `patch` of the declaration an update action's `name` names,
// which is what the platform action route writes.
...(Array.isArray(objectDef?.actions) ? { objectActions: objectDef.actions } : {}),
user: currentUser,
org: actionContextOrg(activeOrganization),
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ describe.each(BLOCKS)('%s — keys the runner acts on, forwarded by the block',
expect(await bodyFor({})).toEqual({ name: 'Acme', source: 'web' });
});

it('`operation: update` reaches the write route with its `patch`, which never rides in the supplied values', async () => {
it('`operation: update` reaches the write route; a `patch` on the node is not forwarded and never rides in the supplied values', async () => {
const script = vi.fn(async () => ({ success: true }));
mount(
node(type, {
Expand All @@ -356,11 +356,13 @@ describe.each(BLOCKS)('%s — keys the runner acts on, forwarded by the block',
);
press();
await waitFor(() => expect(script).toHaveBeenCalledTimes(1));
// The block forwarded `operation` and `patch`, so the update path ran. The
// route applies `patch` from the action's declaration, so the values the
// dispatch posts are the supplied ones alone (objectui#12941): no `stage`.
// The block forwarded `operation`, so the update path ran. The route writes
// the `patch` of the declaration the node's `name` names, never the node's
// own, so the block forwards no `patch` at all (objectui#13022), and the
// values the dispatch posts are the supplied ones alone (objectui#12941).
const dispatched = (script.mock.calls[0] as unknown as [ActionDef])[0];
expect(dispatched).toMatchObject({ operation: 'update', patch: { status: 'closed', stage: 'done' } });
expect(dispatched).toMatchObject({ operation: 'update' });
expect(Object.prototype.hasOwnProperty.call(dispatched, 'patch')).toBe(false);
expect(dispatched.params).toEqual({ recordId: 'R1', status: 'user-chosen' });
cleanup();
script.mockClear();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@ import '../action-bar';
/** The record the action runs on, as the page or the row carries it. */
const RECORD = { id: 't1', status: 'open', title: 'Write the report' };

/**
* The object's declared actions, which the host publishes beside its object
* (`objectActions`, objectui#13022). The route writes the `patch` of the one a
* node's `name` names, so that is what the Undo snapshot covers; a node
* carries no `patch` of its own (`@objectstack/spec` 18 retires the row key).
*/
const DECLARATIONS = [
{ name: 'close_task', label: 'Close', operation: 'update', patch: { status: 'done' }, undoable: true },
// A declaration with no `patch`: it writes the collected params alone.
{ name: 'set_status', label: 'Set status', operation: 'update', undoable: true },
];

type Props = NonNullable<PublicBlockNodeOf<'action:button'>['properties']>;

/** One `action:button` node, its props in the `properties` bag as the spec places them. */
Expand All @@ -61,7 +73,6 @@ const button = (extra: Partial<Props> = {}): PublicBlockNodeOf<'action:button'>
name: 'close_task',
label: 'Close',
operation: 'update',
patch: { status: 'done' },
undoable: true,
...extra,
},
Expand Down Expand Up @@ -95,12 +106,16 @@ afterEach(() => {
globalUndoManager.clear();
});

/** The host's runner, publishing its object the way every console host does (`context.objectName`). */
/**
* The host's runner, publishing its object the way every console host does
* (`context.objectName`), and that object's declarations beside it
* (`context.objectActions`).
*/
function Runner({ children }: { children: React.ReactNode }) {
return (
<ActionProvider
handlers={{ script }}
context={{ objectName: 'task' } as never}
context={{ objectName: 'task', objectActions: DECLARATIONS } as never}
onToast={toast as never}
onParamCollection={(async () => ({ status: 'blocked' })) as never}
>
Expand Down Expand Up @@ -167,9 +182,9 @@ describe('action:button `undoable` — an update of the record in scope offers U
redoData: { status: 'done' },
});
// The write addressed that record, and the stash stayed on the client. The
// `patch` is the route's, applied from the declaration, so the request
// carries no field value at all (objectui#12941) while Undo still covers
// `status`, the field the route wrote.
// `patch` is the route's, applied from the declaration `close_task` names,
// so the request carries no field value at all (objectui#12941) while Undo
// covers `status`, the field the route wrote (objectui#13022).
expect(sentBody()).toEqual({ recordId: 't1', params: {} });
});

Expand Down Expand Up @@ -207,8 +222,9 @@ describe('action:button `undoable` — an update of the record in scope offers U

it('a value the user supplies is restored too, from the same record', async () => {
// The input list collects `status`; the collected value is written, so its
// prior value is what Undo restores.
onRecordPage(button({ patch: undefined, params: [{ name: 'status', type: 'text', label: 'Status' }] }));
// prior value is what Undo restores. `set_status` declares no `patch`, so
// the collected value is the whole write.
onRecordPage(button({ name: 'set_status', params: [{ name: 'status', type: 'text', label: 'Status' }] }));
await press();
expect(globalUndoManager.peekUndo()).toMatchObject({
undoData: { status: 'open' },
Expand Down Expand Up @@ -270,7 +286,7 @@ describe('action:button `undoable` — only an `undoable` update is handed the r
});

it('an `undoable` action that is not an update carries no record', async () => {
onRecordPage(button({ operation: undefined, patch: undefined, actionType: 'script' }));
onRecordPage(button({ operation: undefined, actionType: 'script' }));
await press();
expect(dispatched).toHaveLength(1);
expect(carriedStash()).toBe(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,17 @@ const CONTEXT = { target: 'contextWins', extraFromHost: 'present' };
* exactly the reading these two lists exist to give: the diff shows one added
* name and no re-ordering of the keys the #4281 hoist was about.
*
* `operation` / `patch` (objectui#7551) are the first pair added in the MIDDLE
* rather than at the tail — they sit with `bodyExtra` / `bodyShape` because
* they shape the same request. The reading is unchanged and is why the position
* was acceptable: the list is compared whole, so the failure diff still shows
* `operation` (objectui#7551) was the first key added in the MIDDLE rather
* than at the tail — it sits with `bodyExtra` / `bodyShape` because it shapes
* the same request. The reading is unchanged and is why the position was
* acceptable: the list is compared whole, so the failure diff still shows
* exactly the added names against a sequence of pre-existing keys whose
* relative order did not move. An insertion that DID reorder the neighbours
* would show up here as several moved names, not two added ones.
* would show up here as several moved names, not one added one. Its partner
* `patch` left both lists with objectui#13022: the route writes the `patch` of
* the declaration the action's `name` names, never a node's own, so no
* renderer forwards one. A removal reads the same way: one name gone, no
* neighbour moved.
*
* `outcomeMessages` (objectui#11344) is the next middle insertion, beside
* `successMessage`, the copy it outranks in the success toast. Same reading:
Expand All @@ -113,7 +117,7 @@ const CONTEXT = { target: 'contextWins', extraFromHost: 'present' };
const BUTTON_ORDER = [
'type', 'name', 'label', 'description', 'target', 'openIn', 'endpoint', 'method',
'params',
'bodyExtra', 'bodyShape', 'operation', 'patch', 'confirmText', 'successMessage', 'outcomeMessages', 'errorMessage',
'bodyExtra', 'bodyShape', 'operation', 'confirmText', 'successMessage', 'outcomeMessages', 'errorMessage',
'refreshAfter', 'undoable', 'recordIdField', 'locations', 'toast', 'resultDialog', 'onSuccess', 'objectName',
];

Expand All @@ -125,7 +129,7 @@ const BUTTON_ORDER = [
const ICON_ORDER = [
'type', 'name', 'label', 'description', 'target', 'openIn', 'endpoint', 'method',
'params',
'bodyExtra', 'bodyShape', 'operation', 'patch', 'confirmText', 'successMessage', 'outcomeMessages', 'errorMessage',
'bodyExtra', 'bodyShape', 'operation', 'confirmText', 'successMessage', 'outcomeMessages', 'errorMessage',
'refreshAfter', 'locations', 'toast', 'resultDialog', 'onSuccess', 'objectName',
];

Expand Down
Loading
Loading