Skip to content

Commit c476be0

Browse files
feat(core,react,components,i18n,types): actions honour the spec's outcomeMessages; core and types floor at @objectstack/spec ^17.6.0; the row-5 booking is struck (objectui#11344) (#11552)
Fixes #11344 Clause-②: yes ## What this PR is The renderers now honour the spec's `ActionSchema.outcomeMessages` (`@objectstack/spec` 17.6.0). - **`@object-ui/core`** (`ActionRunner`, `actionKeys`) composes the success toast in this order: an own-property `outcomeMessages` entry, then `successMessage`, then the runner's default. The server's message no longer shows. `SPEC_ACTION_KEYS` and `ACTION_DEF_KEYS` list the key. - **`@object-ui/react`'s `useActionTextLocalizer`** resolves `outcomeMessages` along the spec resolver's address: the object's `_actions` entry, then `globalActions`. - **`@object-ui/components`'** four action renderers forward the key. - **`@object-ui/i18n`** supplies `actionOutcome`. - **`@object-ui/types`** derives the member from the spec. - **Floors:** `@object-ui/core` and `@object-ui/types` raise their `@objectstack/spec` floor to `^17.6.0`, because their published types read `ActionSchema.outcomeMessages`, which 17.6.0 first declares. `types` already reads `^17.6.0` on `main` (objectui#11227's floor commit), so only the `core` importer's specifier differs from `main` in the lockfile. ## The row-5 booking is struck objectui#11531 landed with row 5 booked to this card (`OWED_TO_OBJECTUI_11344` in `actionKeys.pin.test.ts`). This PR removes it in full, together with `.changeset/11344-row5-booking-17-6.md`. The pin file's blob is byte-identical to its pre-booking state (`3445b86f1`), and the spec-inventory row is back to `missing: []`, `stale: []`. ## Reviews - Contract review FAIL `5968432379` (floors), then REWORK `5968446861`, then the delta contract review **PASS** `5969179064` on `89b21dafd`. - The director's takeover claim is `5969091930`. - **This head** merges `main` and strikes the booking, as the PASS's landing condition ③.1 requires. It also carries the PASS's two wording notes: objectui#10969's pending changeset, and the `completedSuccessfully` comment in `packages/i18n/src/locales/en.ts`. No contract surface moved beyond the reviewed head. ## Tests (dev report `5969817428`) - 8 files, 140 tests green; - type-check passes for core, types, react, i18n and components after a full build; - `check:spec-floors --cross-check` is green (21 of 39 packages, 435 pairs); - the lockfile integrity and dedupe checks are clean; - the changeset gates and citations (0 new) pass. ## Changeset `.changeset/11344-action-outcome-messages.md`: five `minor` entries, no `major`, with the floor sentence. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 584eeca commit c476be0

26 files changed

Lines changed: 854 additions & 155 deletions

‎.changeset/10900-zh-cn-leftovers.md‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,13 @@
1010
Four Console surfaces that read English under a zh-CN session now read the session's
1111
language (objectui#10900). English stays the default.
1212

13-
- **The generic action success toast.** When an action declares no `successMessage` and
14-
the server returns no message, the runner falls back to "Action completed successfully".
13+
- **The generic action success toast.** When an action declares no `successMessage`, the
14+
runner falls back to "Action completed successfully".
1515
That fallback now goes through a translator: `ActionRunner.setTranslator(translate)` is
1616
new in `@object-ui/core`, and `<ActionProvider>` and `useActionRunner` in
1717
`@object-ui/react` install the session's `t` on the runner they build. The pack key is
18-
`actions.completedSuccessfully`. An author's `successMessage` and a server-returned
19-
message still reach the toast verbatim, and a runner with no translator installed still
20-
toasts the English sentence.
18+
`actions.completedSuccessfully`. An author's `successMessage` still reaches the toast
19+
untranslated, and a runner with no translator installed still toasts the English sentence.
2120
- **The social sign-in buttons on the login and sign-up pages.** `SocialSignInButtons`
2221
takes a new `buttonText` prop, a template whose `{provider}` is replaced with the
2322
provider's display name; unset, the buttons keep "Continue with {provider}" and

‎.changeset/10969-console-zh-leftovers.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
The console strings objectui#10900 left English under zh-CN now read the session's language (objectui#10969).
88

9-
- **The action runner's other own text.** `ActionRunner` asks the translator installed through `setTranslator` (objectui#10900) for three more strings it writes itself: the error toast when the error that reached it carries no readable message (`actions.failed`), a parallel chain's error when no failed action reported one (`actions.parallelFailed`), and the label of an undoable success toast's Undo button (`actions.undo`). An author's `errorMessage` / `successMessage`, a server message and an action's own error stay verbatim. With no translator the text stays English.
9+
- **The action runner's other own text.** `ActionRunner` asks the translator installed through `setTranslator` (objectui#10900) for three more strings it writes itself: the error toast when the error that reached it carries no readable message (`actions.failed`), a parallel chain's error when no failed action reported one (`actions.parallelFailed`), and the label of an undoable success toast's Undo button (`actions.undo`). An author's `errorMessage` / `successMessage` and an action's own error stay untranslated. With no translator the text stays English.
1010
- **The Undo label now arrives from the runner.** For an undoable result the runner hands the toast handler `undo: { label }` where it used to hand `undo: {}`; `ToastHandler`'s type is unchanged (`label` was already optional). A handler that renders `options.undo.label` shows the translated label; one that ignores it keeps its own.
1111
- **The console app's `system/*` breadcrumbs.** In `AppHeader`, the segment after `System` for `settings`, `apps`, `profile`, `approvals`, `ai-approvals` and `audit-log` reads `console.breadcrumb.*` (as `marketplace` has since objectui#10900). In English, `ai-approvals` now reads `AI Approvals` instead of the humanized slug `Ai Approvals`; the other five read as before. A segment the header does not know keeps its humanized slug.
1212
- **The Build Doctor drawer's body.** Its description, loading and not-found states, summary line, verdict, and each section's title and hint read `console.ai.buildDoctorDrawer.*`. The report's own data (tool, artifact and status names, timeline text) stays verbatim.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@object-ui/core': minor
3+
'@object-ui/react': minor
4+
'@object-ui/i18n': minor
5+
'@object-ui/components': minor
6+
'@object-ui/types': minor
7+
---
8+
9+
The action success toast is composed from the action's `outcomeMessages`, then its `successMessage`, then the runner's default text. A `message` in the server's answer is no longer shown (objectui#11344).
10+
11+
`@objectstack/spec` 17.6.0 declares `ActionSchema.outcomeMessages`, the success copy for each handler outcome. Its keys are the snake_case `outcome` values that a `type: 'api'` or `type: 'script'` handler returns in its success payload. This follows ruling A on objectstack-ai/cloud#2315: the server returns facts, and the console writes the sentence in the user's locale.
12+
13+
- `@object-ui/core`: the `ActionRunner` success toast now picks its text in this order:
14+
1. the `outcomeMessages` entry named by the top-level `outcome` of the handler's answer;
15+
2. `successMessage`;
16+
3. the runner's default text, in the language of the translator set with `setTranslator`.
17+
18+
Rungs 1 and 2 fill in `${result.*}` tokens from the handler's answer. That is the same scope `onSuccess.navigate` reads, and the values go in as text, not percent-encoded. **Behaviour change:** `result.data.message` used to take precedence over both. The runner no longer reads it at all. To show the server's sentence, write `${result.message}` as the copy. `ActionDef` now declares `outcomeMessages`.
19+
- `@object-ui/react`: `useActionTextLocalizer` resolves each `outcomeMessages` entry that a named action declares. It looks in `_actions.NAME.outcomeMessages.OUTCOME`, then `globalActions.NAME.outcomeMessages.OUTCOME`, then falls back to the authored text. On a named action, an inline `I18nLabel` map on an entry, or on `successMessage`, is collapsed to the active language first. `${result.*}` tokens pass through unchanged, and the runner fills them in after the action runs.
20+
- `@object-ui/i18n`: `useObjectLabel()` adds `actionOutcome(objectName, actionName, outcome, fallback)`, the resolver for that address.
21+
- `@object-ui/components`: `action:button`, `action:icon`, `action:group` and `action:menu` now forward `outcomeMessages` to the runner. Before, a registered action rendered through `action:bar` lost the map one hop before the toast. The `action:button` and `action:icon` registrations do not publish it as an input, because their own spec rows do not declare it at 17.6.0.
22+
- `@object-ui/types`: `UIActionSchema` declares `outcomeMessages`, derived from the spec.
23+
24+
`@object-ui/core` and `@object-ui/types` raise their `@objectstack/spec` floor from `^17.5.0` to `^17.6.0`, because their published types now read `ActionSchema.outcomeMessages`, a member the spec first declares in 17.6.0.

‎.changeset/11344-row5-booking-17-6.md‎

Lines changed: 0 additions & 4 deletions
This file was deleted.

‎content/docs/core/enhanced-actions.mdx‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -312,18 +312,28 @@ authored callback fails at the authoring site with the migration in the message.
312312
What to write instead:
313313

314314
- **A notice** — `successMessage` / `errorMessage`, plain strings (the runner
315-
surfaces `successMessage` as a toast after a successful action). The runner
316-
shows `successMessage` as written. Without one, and without a message from
317-
the server, it falls back to a generic "Action completed successfully".
315+
surfaces `successMessage` as a toast after a successful action). An action
316+
whose handler can succeed in more than one way can also carry the spec's
317+
`outcomeMessages` (on `UIActionSchema`, for `type: 'api'` and `type: 'script'`):
318+
one message per snake_case `outcome` the handler returns in its answer. The
319+
success toast shows the `outcomeMessages` entry the answer's `outcome` names,
320+
else `successMessage` (objectui#11344). Both may read the handler's answer as
321+
`${result.*}` — `'Environment ${result.name} archived'` — the same scope
322+
`onSuccess.navigate` reads. A `message` in the server's answer is not shown
323+
unless the copy asks for it as `${result.message}`. Without either, the
324+
runner falls back to a generic "Action completed successfully".
318325
That fallback is text the runner writes itself, and so are the "Action
319326
failed" error toast for an error with no readable message, a parallel
320327
chain's "One or more parallel actions failed", and the label it hands your
321328
toast handler for an undoable success toast's Undo button
322329
(`options.undo.label`). Those are the strings it translates; an author's
323-
`successMessage` / `errorMessage` and a server message are never
324-
translated. `<ActionProvider>` and `useActionRunner` install the session's
325-
translator on their runner, and a runner you build yourself takes one
326-
through `runner.setTranslator(t)`.
330+
`outcomeMessages` / `successMessage` / `errorMessage` never go through the
331+
runner's translator. On a named action, `@object-ui/react`'s `useActionTextLocalizer`
332+
resolves `successMessage` and each `outcomeMessages` entry (its bundle entry,
333+
else the inline locale map) before the action runs.
334+
`<ActionProvider>` and `useActionRunner` install the session's translator on
335+
their runner, and a runner you build yourself takes one through
336+
`runner.setTranslator(t)`.
327337
- **Post-success navigation** — the spec's `onSuccess` block, `{ navigate, openIn }`,
328338
declared on `UIActionSchema` and forwarded to the runner (objectui#5934). It is
329339
a spec key, not a member of this legacy type, so it is not shown in a fence here.

‎packages/app-shell/src/hooks/useConsoleActionRuntime.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -481,8 +481,9 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
481481
// Unwrap the ObjectStack `{ success, data }` envelope so `result.data`
482482
// is the inner payload — the contract every `result.data` consumer
483483
// expects. The action `resultDialog` field paths (e.g. `user.email`,
484-
// `temporaryPassword`) and the dynamic-toast `result.data.message` are
485-
// all written relative to the inner `data`. flowHandler and
484+
// `temporaryPassword`) and the success toast's `outcome` and
485+
// `${result.*}` reads (objectui#11344) are all written relative to the
486+
// inner `data`. flowHandler and
486487
// serverActionHandler already unwrap `json.data`; apiHandler was the
487488
// lone handler that leaked the whole envelope, which blanked every
488489
// resultDialog whose paths didn't redundantly prefix `data.` (the

‎packages/components/src/renderers/action/__tests__/action-forward-precedence.test.tsx‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,16 @@ const CONTEXT = { target: 'contextWins', extraFromHost: 'present' };
105105
* exactly the added names against a sequence of pre-existing keys whose
106106
* relative order did not move. An insertion that DID reorder the neighbours
107107
* would show up here as several moved names, not two added ones.
108+
*
109+
* `outcomeMessages` (objectui#11344) is the next middle insertion, beside
110+
* `successMessage`, the copy it outranks in the success toast. Same reading:
111+
* one added name, no neighbour moved.
108112
*/
109113
const BUTTON_ORDER = [
110114
'type', 'name', 'label', 'description', 'target', 'openIn', 'endpoint', 'method',
111115
'params',
112-
'bodyExtra', 'bodyShape', 'operation', 'patch', 'confirmText', 'successMessage', 'errorMessage', 'refreshAfter',
113-
'undoable', 'recordIdField', 'locations', 'toast', 'resultDialog', 'onSuccess', 'objectName',
116+
'bodyExtra', 'bodyShape', 'operation', 'patch', 'confirmText', 'successMessage', 'outcomeMessages', 'errorMessage',
117+
'refreshAfter', 'undoable', 'recordIdField', 'locations', 'toast', 'resultDialog', 'onSuccess', 'objectName',
114118
];
115119

116120
/**
@@ -121,8 +125,8 @@ const BUTTON_ORDER = [
121125
const ICON_ORDER = [
122126
'type', 'name', 'label', 'description', 'target', 'openIn', 'endpoint', 'method',
123127
'params',
124-
'bodyExtra', 'bodyShape', 'operation', 'patch', 'confirmText', 'successMessage', 'errorMessage', 'refreshAfter',
125-
'locations', 'toast', 'resultDialog', 'onSuccess', 'objectName',
128+
'bodyExtra', 'bodyShape', 'operation', 'patch', 'confirmText', 'successMessage', 'outcomeMessages', 'errorMessage',
129+
'refreshAfter', 'locations', 'toast', 'resultDialog', 'onSuccess', 'objectName',
126130
];
127131

128132
// See action-bodyExtra-forward.test.tsx for why this is not
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#11344 — `ActionSchema.outcomeMessages` must SURVIVE the hop on all
11+
* four declared action surfaces, and on the `action:bar` path a registered
12+
* action actually takes to them.
13+
*
14+
* Each action renderer hands the runner an explicit key whitelist, so a key the
15+
* runner reads stays dropped until every whitelist carries it — the
16+
* objectstack#6837 shape. `check:action-forward-parity` asks that statically,
17+
* once the runner reads the key off the forwarded def; these rows measure the
18+
* same thing at run time, at the artefact the user sees: the success toast.
19+
*
20+
* ## Why the toast, and why the answer carries a `message`
21+
*
22+
* Every row's handler answers `{ outcome: 'archived', name: 'prod', message }`.
23+
* With the map dropped, the toast would read `successMessage` (the second
24+
* rung); with the retired server-message rung back, it would read `message`.
25+
* Only the forwarded map, picked by `outcome` and interpolated, produces
26+
* `Environment prod archived` — so neither regression can pass a row.
27+
*
28+
* Each row first asserts the handler ran exactly once (the positive control), so
29+
* a wrong toast is never "the harness executed nothing".
30+
*
31+
* ## The `action:bar` row is the producer path
32+
*
33+
* An object's registered actions reach a record header as `action:bar` members,
34+
* and the bar spreads each member WHOLE onto an `action:button` node
35+
* (`renderMember` in `action-bar.tsx`). So the map on a registered action
36+
* arrives at `action:button` without anyone authoring it there; dropped by that
37+
* whitelist, it never reached the runner. `element:button` is absent for the
38+
* reason `action-onSuccess-forward.test.tsx` gives: the spec's
39+
* `InlineActionSchema` pick list does not carry the key.
40+
*/
41+
42+
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
43+
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
44+
import React from 'react';
45+
import { ComponentRegistry } from '@object-ui/core';
46+
import type { ActionContext, ActionDef, ActionResult, ToastHandler } from '@object-ui/core';
47+
import { ActionProvider } from '@object-ui/react';
48+
// Module-scope side-effect imports — these renderers register themselves with
49+
// the ComponentRegistry, and the light `dom` project does not load the
50+
// `@object-ui/components` graph. Module scope, not a `beforeAll`, per
51+
// AGENTS.md §测试纪律.
52+
import '../action-button';
53+
import '../action-icon';
54+
import '../action-group';
55+
import '../action-menu';
56+
import '../action-bar';
57+
58+
/** The handler's own return value — what `outcome` and `${result.*}` read. */
59+
const PAYLOAD = { outcome: 'archived', name: 'prod', message: 'Environment prod archived (server English).' };
60+
61+
const OUTCOME_MESSAGES = {
62+
archived: 'Environment ${result.name} archived',
63+
already_archived: 'Environment ${result.name} was already archived',
64+
};
65+
66+
/** The registered action, as an object's `actions` array declares it. */
67+
const declaration = (extra: Record<string, unknown> = {}) => ({
68+
name: 'delete_environment',
69+
label: 'Delete environment',
70+
type: 'script',
71+
locations: ['record_header'],
72+
outcomeMessages: OUTCOME_MESSAGES,
73+
successMessage: 'Environment ${result.name} updated',
74+
...extra,
75+
});
76+
77+
/** As a NODE handed straight to a leaf, the execution type is `actionType` (objectui#7415). */
78+
const asNode = (component: string, decl: Record<string, unknown>) => {
79+
const { type, ...rest } = decl;
80+
return { ...rest, type: component, actionType: type };
81+
};
82+
83+
let script: Mock<(action: ActionDef, ctx: ActionContext) => Promise<ActionResult>>;
84+
let toast: Mock<ToastHandler>;
85+
86+
beforeEach(() => {
87+
script = vi.fn(async () => ({ success: true, data: PAYLOAD }));
88+
toast = vi.fn();
89+
});
90+
91+
function surface(type: string, schema: Record<string, unknown>) {
92+
const C = ComponentRegistry.get(type);
93+
if (!C) throw new Error(`${type} is not registered`);
94+
return <C schema={schema as never} />;
95+
}
96+
97+
const renderSurface = (node: React.ReactNode) =>
98+
render(
99+
<ActionProvider handlers={{ script }} onToast={toast}>
100+
{node}
101+
</ActionProvider>,
102+
);
103+
104+
/** The positive control, the forward, and the toast the user reads. */
105+
async function expectOutcomeToast() {
106+
await waitFor(() => expect(script).toHaveBeenCalledTimes(1));
107+
expect(script.mock.calls[0][0].outcomeMessages).toEqual(OUTCOME_MESSAGES);
108+
await waitFor(() => expect(toast).toHaveBeenCalledTimes(1));
109+
expect(toast.mock.calls[0][0]).toBe('Environment prod archived');
110+
expect(toast.mock.calls[0][1]).toEqual(expect.objectContaining({ type: 'success' }));
111+
}
112+
113+
describe('ActionSchema.outcomeMessages reaches the success toast from every declared surface (objectui#11344)', () => {
114+
it('action:bar → action:button — a registered action spread onto the button keeps its outcome copy', async () => {
115+
const Bar = ComponentRegistry.get('action:bar');
116+
if (!Bar) throw new Error('action:bar is not registered');
117+
// Both ceilings high, so the member stays inline on any viewport (see
118+
// `action-bar-member-type-resolution.test.tsx`).
119+
renderSurface(
120+
<Bar
121+
schema={{ type: 'action:bar', maxVisible: 10, mobileMaxVisible: 10, actions: [declaration()] } as never}
122+
/>,
123+
);
124+
125+
fireEvent.click(screen.getByRole('button', { name: 'Delete environment' }));
126+
127+
await expectOutcomeToast();
128+
});
129+
130+
it('action:button — a node carrying the map', async () => {
131+
renderSurface(surface('action:button', asNode('action:button', declaration())));
132+
133+
fireEvent.click(screen.getByRole('button', { name: 'Delete environment' }));
134+
135+
await expectOutcomeToast();
136+
});
137+
138+
it('action:icon — the icon-only surface', async () => {
139+
renderSurface(surface('action:icon', asNode('action:icon', declaration())));
140+
141+
fireEvent.click(screen.getByRole('button', { name: 'Delete environment' }));
142+
143+
await expectOutcomeToast();
144+
});
145+
146+
it('action:group — an inline group member', async () => {
147+
renderSurface(surface('action:group', { type: 'action:group', actions: [declaration()] }));
148+
149+
fireEvent.click(screen.getByRole('button', { name: 'Delete environment' }));
150+
151+
await expectOutcomeToast();
152+
});
153+
154+
it('action:menu — an overflow item', async () => {
155+
// `autoTrigger` runs `handleExecute`, the function a click on the menu item
156+
// calls, without opening the Radix dropdown (see
157+
// `action-onSuccess-forward.test.tsx`).
158+
renderSurface(surface('action:menu', { type: 'action:menu', actions: [declaration({ autoTrigger: true })] }));
159+
160+
await expectOutcomeToast();
161+
});
162+
});

0 commit comments

Comments
 (0)