Skip to content

Commit 0ac6dab

Browse files
feat(console): name the records a bulk run wrote, hint which required fields to fill, and show CRM expected revenue as money (#28378)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoDdYQJbM1obWY7sMWJcH9sv)_ Part of #27520 (rows 8, 11 and 16; every other row stays on the card) Clause-②: yes (widening: `ActionResult` gains the optional `fieldErrors` member, `ActionFieldError` is a new export of `@object-ui/types`, and `ActionResponseOutcome` gains `fieldErrors`; no accept set moves) Metadata-protocol: none ## 维护者速读 - **改了什么**:批量动作(如 Tasks 的「Mark Done」)结果弹窗不再只写「Succeeded 2 / 2」,而是列出成功的记录名;动作因必填字段被拒(如草稿发票「Submit for Sign-off」缺 Issued On)时,提示变成「Issued On is required. Fill in Issued On, then run “Submit for Sign-off” again.」;CRM 示例的 Expected Revenue 公式声明为货币,与旁边的 Deal Value 同样显示为 `$16,000.00`。 - **为什么改**:18.3.0 RC 走查第 8、11、16 行:用户看不出批量改了哪些记录;被拒后不知道要先去编辑哪个字段;同一表格里两列金额一列带货币一列是普通数字。 - **风险与回滚**:批量名单只在每个失败都对应到具体记录时才显示,服务端只回计数时保持原样;提示只追加在服务端原文之后,只针对 `required` 字段项;`ActionResult` 多一个可选成员(放宽,不收紧)。回滚即 revert 本 PR。 - **先看**:`ui/packages/app-shell/src/utils/consoleServerAction.ts`(`withRequiredFieldsHint`);`ui/packages/core/src/actions/actionResponse.ts`(`refusalFieldErrors`);`ui/packages/plugin-grid/src/components/BulkActionDialog.tsx`(`succeededRows`)。 - **两分钟验证**:showcase 打开一张无 Issued On 的草稿发票点「Submit for Sign-off」,预期红色提示以「Fill in Issued On, then run “Submit for Sign-off” again.」结尾;Tasks → Bulk Actions 视图勾两行点 Mark Done,预期结果弹窗列出两条任务标题。 - **需要你做的**:无,席位落地。 ## What changes **Row 8, bulk result names the records.** `BulkActionDialog`'s result step now lists the records the run wrote under the "Succeeded N / M" line, named the way the confirm step names the affected records (`affectedRecordName`, the object's `nameField`), five at most plus "… and N more". The list is drawn only when every failure in the result sits under a record of the run, so the rows left over are exactly the successes. When a bulk call answered a bare count (`executeBulkBatch`'s `batch_<offset>` entry), which records failed is unknown and the result keeps to its count. No server read and no executor change. New key `grid.bulk.succeededRecords` (the list's accessible name) in all ten packs. **Row 11, a required-field refusal says what to fill in.** The action route answers a refused write with `error.details.fields` (one entry per failed rule, measured live: `{ field: 'issued_on', code: 'required', message: 'Issued On is required', label: 'Issued On' }`). `interpretActionResponse` kept only the text. Now: - `interpretActionResponse` reads the entries (current envelope `error.details.fields`, legacy inner envelope `fields`), drops entries without a string `field`, and returns them as `fieldErrors`; - `createServerActionHandler` passes them on the failed `ActionResult` (`fieldErrors?: ActionFieldError[]`, new optional member in `@object-ui/types`); - the console wrapper `createConsoleServerActionHandler` follows the server's sentence with `console.serverAction.fillRequiredThenRetry` ("Fill in {{fields}}, then run “{{action}}” again.") or, for an action with no label, `fillRequiredFirst`. Only `required` entries on a field count (`_record` and every other code keep the server's text alone). Field names are the server's labels, deduplicated and joined with `validation.formInvalidJoiner`, the key the form's field lists already join with (`Intl.ListFormat` is outside app-shell's lib target, and a `unit` list joins zh with no separator). The server's text stays first and whole; a stop is added only when it has none (a CJK one after CJK text). The wrapper's `t` now takes interpolation values; both call sites (`useConsoleActionRuntime`, `RecordDetailView`) pass them through to i18next as `{ defaultValue, ...values }`. - An authored `errorMessage` still replaces the whole text in the runner, hint included, as before. **Row 16, money formula declares money.** `examples/app-crm/src/objects/opportunity.object.ts`: `expected_revenue` declares `returnType: 'currency'`. The renderer reads `returnType` and never infers money from an expression (`FormulaCellRenderer`, objectui#11683 / #11748), so the undeclared formula drew the number face (`16,000`) beside the `Field.currency` Deal Value (`$80,000.00`). The seat lifted the order's no-examples line for exactly this edit (claim amendment 6539593257). No renderer change. ## Tests - New `ui/packages/plugin-grid/src/__tests__/bulkResultSucceededNames-27520.test.tsx` (3): a clean run lists both titles under the accessible name; a run with one refusal lists only the two that landed and keeps the refused one under its refusal; a `bulkUpdate` answering a bare count names nobody. - New `ui/packages/core/src/actions/__tests__/actionRefusalFields-27520.test.ts` (5): the measured answer yields `fieldErrors`; an answer that also carries a producer-marked `error.userMessage` keeps them (the hint keys off the entries, never the text, so it survives a `userMessage`); field-less entries are dropped; the legacy inner list is read; the dispatch's failed result carries the entries. - New `ui/packages/app-shell/src/utils/__tests__/consoleServerAction.requiredFieldsHint-27520.test.ts` (6): the hint after the server's sentence; several fields deduplicated and joined with the pack joiner (zh `、`); a label-less field and a label-less action; `min` and `_record` entries left alone; the host `t` receives the values; the server's own stop kept and a CJK stop added. - Adapted `ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts`: the de census counts the new „{{action}}“ span, with the same one-line history note its earlier rises carry. main's #28310 raised the same pins by one while this was open, so after merging main (6a3bd4e) they read 103 paired spans and 103 / 103 / 0, both rises noted. `pnpm --dir ui exec vitest run --maxWorkers=2 packages/i18n/` on the merge head: 93 files passed (1433 tests, 13 skipped). - Ablations (one-time, not kept, at 714e376, before the `userMessage` case was added), through `scripts/ablation-replace.mjs`, each restored to the HEAD blob with `git diff HEAD` empty: succeeded list never drawn → 2 failed of 3; count guard removed → 1 failed of 3; core stops returning `fieldErrors` → 8 failed of 10 (core + wrapper files); wrapper returns the refusal unhinted → 5 failed of 6. The suites resolve `@object-ui/*` to source, so no dist leg applies. - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/types/ packages/core/ packages/i18n/ packages/plugin-grid/`: at 714e376: 963 files passed, 1 failed (`de-quote-pairing-3876`, 2 tests: its census counted 101 paired „…“ spans and the new `fillRequiredThenRetry` adds one). The pins were raised to 102 in 9f34e7d and the file reran 9/9 passed. The new `userMessage` case in e578f93 ran alone, 5/5 passed. - app-shell, the wrapper and both call sites: `pnpm --dir ui exec vitest run --maxWorkers=2 packages/app-shell/src/utils/__tests__/consoleServerAction packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime packages/app-shell/src/views/RecordDetailView`: 108 files / 761 tests passed (lock VERDICT command-exit 0). The whole `packages/app-shell/` run sat 80 minutes under the lock without finishing on this shared box and was stopped; the rest of app-shell is left to CI. - `type-check` of `@object-ui/types`, `core`, `i18n`, `plugin-grid`, `app-shell` (full dts closure built first): all Done, 0 `error TS`. `@objectstack/example-crm`: `typecheck` exit 0, `test` 7 files / 48 tests passed, `validate` exit 0 (only the pre-existing `field-no-consumers` warnings on `renewal_of` / `line_total`). - Dogfood that reads the CRM example's metadata (asked for by the seat for the example edit), against the built `examples/app-crm/dist/objectstack.json` that carries `returnType: 'currency'`: `pnpm --filter @objectstack/dogfood exec vitest run test/install-local-purge-sample-data.dogfood.test.ts test/wall-environment-metadata-refusal.dogfood.test.ts test/guest-grants-channel.dogfood.test.ts` → 3 files / 53 tests passed (lock VERDICT command-exit 0). The other dogfood files read only the CRM's flows or views, which this PR leaves alone. ## Acceptance notes - **Row 8's Status half is the example's own declaration**, not touched: `examples/app-showcase/src/ui/actions/index.ts` `MarkDoneAction` patches `done` and `progress` on purpose, because `status` is governed by `task_status_flow` (only `in_review -> done` is legal), and its doc comment says so. - **Row 11's "mark fields required for submission" half is not done.** The rule exists (`issued_on` `requiredWhen: record.status in ['sent', 'paid']`), and the edit form evaluates it on the record as it stands, so a draft correctly shows Issued On as optional. Saying "required for Submit for Sign-off" means tying a field's rule to an action's `patch`, which no spec door declares. The seat files that as a spec card. - The bulk path runs a promoted object action per record through the same wrapper, so a required-field refusal there now reads with the hint under the refused record too. - The action toast's text comes from `actionErrorDetail`, which reads `error.message` and never `error.userMessage` (a flow-action refusal can carry one). That is unchanged here; the hint rides after whichever text is shown, because it is keyed off `details.fields`. Noted, not filed. - Row 16 also applies to any author: a money formula with no `returnType` draws a plain number by rule. `os validate` does not warn about it. Noted, not filed. ## Verification <details><summary>Live before/after (showcase and CRM on --fresh stacks, admin, 1440x900)</summary> Base `origin/main` b1e1dcf (before) and this branch (after), full dts build plus `pnpm console:build`, `LANG=en_US.UTF-8 pnpm dev -- --fresh -p 38611` (showcase) and `pnpm --filter @objectstack/example-crm dev -- --fresh -p 38612`, driven with Playwright. - Row 8, Tasks → Bulk Actions view, Content backlog + Evidence collection → Mark Done → Run. Before: dialog text `Mark Done | Succeeded 2 / 2 | Done`. After: `Mark Done | Succeeded 2 / 2 | • Evidence collection | • Content backlog | Done`. - Row 11, INV-1002 (draft, no Issued On) → Submit for Sign-off. Wire both times: `400 {"success":false,"error":{"code":"VALIDATION_FAILED","message":"Issued On is required","httpStatus":400,"details":{"fields":[{"field":"issued_on","code":"required","message":"Issued On is required","label":"Issued On"}]}}}`. Toast before: `Issued On is required`. After: `Issued On is required. Fill in Issued On, then run “Submit for Sign-off” again.` - Row 16, CRM → Opportunities, row Initech — Expansion. Before: Deal Value `$80,000.00`, Expected Revenue `16,000`. After: `$80,000.00`, `$16,000.00`. </details> <details><summary>Derived gates</summary> At 6a3bd4e (this branch merged with main 0f29a88): `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 54 families against the diff. All 54 ran with `</dev/null`, exit codes recorded (`27520-actions-gates-ran-6a3bd4e7.list` in the seat's out folder). `dispatch-gates --ran`: "54 derived famil(ies) accounted for — 54 run, 0 NOT-MEASURED". - `pnpm check:type-check-debt` first exited 3 (not measured): the closure build could not resolve `@objectstack/observability`, a package main added, because the tree had not been reinstalled after the merge. After `pnpm install --frozen-lockfile` it reran: exit 0, "0 raw tsc error(s) total, none above its recorded number". The list records the rerun. - `pnpm --dir ui exec vitest run scripts/` ran under the verify lock: exit 0. - Also run: `pnpm --dir ui run check:new-line-citations` exit 0; `node scripts/check-sdui-manifest.mjs` exit 0. - The same 54 families were derived and all exited 0 at 714e376, before the last test commit and the merge. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibzF2YJvme2ABRytQ9o)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fc5d509 commit 0ac6dab

24 files changed

Lines changed: 479 additions & 14 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/types': patch
3+
'@object-ui/core': patch
4+
'@object-ui/app-shell': patch
5+
'@object-ui/plugin-grid': patch
6+
'@object-ui/i18n': patch
7+
---
8+
9+
feat(console): a bulk result names the records it wrote, and a required-field refusal says what to fill in
10+
11+
Clause-②: yes (widening: `ActionResult` gains the optional `fieldErrors` member, `ActionFieldError` is a new export of `@object-ui/types`, and `ActionResponseOutcome` gains `fieldErrors`; no accept set moves)
12+
Metadata-protocol: none
13+
14+
- **Bulk action result.** After a bulk action such as Mark Done, the result read only "Succeeded 2 / 2". It now lists the records that succeeded, named the way the confirm step names them (the object's name field). The list appears when every failure is reported against a record of the run; when the server answered a bare count, the result keeps to the count rather than name a record that may have failed.
15+
- **Required-field refusal from an action.** Running an action whose write the server refuses for a missing required field, such as Submit for Sign-off on a draft invoice with no Issued On, used to say only "Issued On is required". It now continues "Fill in Issued On, then run “Submit for Sign-off” again." Several fields are joined in the session's language. Any other refusal is shown as the server wrote it.
16+
- For code that dispatches actions through `createServerActionHandler`: a failed `ActionResult` now carries the refusal's per-field entries as `fieldErrors` (field, code, message, label), read from the answer's `error.details.fields`. `error` is unchanged.

‎examples/app-crm/src/objects/opportunity.object.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,12 @@ export const Opportunity = ObjectSchema.create({
4747
min: 0,
4848
max: 100,
4949
}),
50+
// An amount of money, so it declares the `currency` return type: the
51+
// console reads `returnType` and never infers money from the expression,
52+
// and an undeclared formula draws the plain number face beside `amount`.
5053
expected_revenue: Field.formula({
5154
label: 'Expected Revenue',
55+
returnType: 'currency',
5256
expression: cel`(record.amount == null ? 0 : record.amount) * (record.probability == null ? 0 : record.probability) / 100`,
5357
}),
5458
close_date: Field.date({

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -819,7 +819,8 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
819819
// Read through the env ref so the spinner-tab / popup-blocked copy
820820
// follows a language switch without invalidating the handler instance
821821
// (objectui#3321).
822-
t: (key, englishDefault) => String(serverActionEnvRef.current.t(key, englishDefault)),
822+
t: (key, englishDefault, values) =>
823+
String(serverActionEnvRef.current.t(key, { defaultValue: englishDefault, ...values })),
823824
}),
824825
[authFetch],
825826
);
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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+
* objectstack-ai/objectstack#27520 row 11 — a refused action that names
11+
* required fields says what to do next.
12+
*
13+
* Measured on the showcase: Submit for Sign-off on a draft invoice answered
14+
* `Issued On is required` and nothing else, so the user had no hint that the
15+
* invoice needed editing first. The console's action dispatch now follows the
16+
* server's sentence with "Fill in Issued On, then run “Submit for Sign-off”
17+
* again." The server's text stays first and whole, whatever it says.
18+
*/
19+
20+
import { describe, it, expect, vi } from 'vitest';
21+
import type { ActionDef, ServerActionFetch } from '@object-ui/core';
22+
import { createConsoleServerActionHandler } from '../consoleServerAction';
23+
24+
const SUBMIT = { type: 'script', name: 'showcase_submit_signoff', label: 'Submit for Sign-off', objectName: 'showcase_invoice', recordId: 'inv_1' } as ActionDef;
25+
26+
function refusing(message: string, fields: unknown[]) {
27+
return vi.fn(async () => ({
28+
ok: false,
29+
status: 400,
30+
json: async () => ({ success: false, error: { code: 'VALIDATION_FAILED', message, details: { fields } } }),
31+
})) as unknown as ServerActionFetch;
32+
}
33+
34+
function handlerOver(fetch: ServerActionFetch, t?: (key: string, englishDefault: string, values?: Record<string, string>) => string) {
35+
return createConsoleServerActionHandler({
36+
fetch,
37+
baseUrl: () => '',
38+
resolveObject: () => 'showcase_invoice',
39+
onRefresh: () => {},
40+
t,
41+
});
42+
}
43+
44+
describe('a required-field refusal names what to fill in (objectstack#27520 row 11)', () => {
45+
it('follows the server’s sentence with the field and the action to run again', async () => {
46+
const res = await handlerOver(refusing('Issued On is required', [
47+
{ field: 'issued_on', code: 'required', message: 'Issued On is required', label: 'Issued On' },
48+
]))(SUBMIT);
49+
expect(res.success).toBe(false);
50+
expect(res.error).toBe('Issued On is required. Fill in Issued On, then run “Submit for Sign-off” again.');
51+
});
52+
53+
it('joins several required fields with the pack’s list punctuation, each once', async () => {
54+
const fields = [
55+
{ field: 'issued_on', code: 'required', label: 'Issued On' },
56+
{ field: 'due_on', code: 'required', label: 'Due On' },
57+
{ field: 'issued_on', code: 'required', label: 'Issued On' },
58+
];
59+
const en = await handlerOver(refusing('Two fields are required', fields))(SUBMIT);
60+
expect(en.error).toBe('Two fields are required. Fill in Issued On, Due On, then run “Submit for Sign-off” again.');
61+
const zhJoiner = (key: string, englishDefault: string, values?: Record<string, string>) =>
62+
key === 'validation.formInvalidJoiner' ? '、' : englishDefault.replace(/\{\{(\w+)\}\}/g, (_h, n: string) => values?.[n] ?? '');
63+
const zh = await handlerOver(refusing('两个字段为必填项', fields), zhJoiner)(SUBMIT);
64+
expect(zh.error).toContain('Fill in Issued On、Due On,');
65+
});
66+
67+
it('keeps the server’s own stop, and ends a CJK sentence with its own', async () => {
68+
const fields = [{ field: 'issued_on', code: 'required', label: '开具日期' }];
69+
const stopped = await handlerOver(refusing('Issued On is required.', fields))(SUBMIT);
70+
expect(stopped.error?.startsWith('Issued On is required. Fill in')).toBe(true);
71+
const zh = await handlerOver(refusing('开具日期为必填项', fields))(SUBMIT);
72+
expect(zh.error?.startsWith('开具日期为必填项。Fill in 开具日期')).toBe(true);
73+
});
74+
75+
it('names the field by its key when the server sent no label, and asks plainly when the action has none', async () => {
76+
const res = await handlerOver(refusing('due_on is required', [{ field: 'due_on', code: 'required' }]))(
77+
{ ...SUBMIT, label: undefined } as ActionDef,
78+
);
79+
expect(res.error).toBe('due_on is required. Fill in due_on first.');
80+
});
81+
82+
it('leaves every other refusal as the server said it', async () => {
83+
const res = await handlerOver(refusing('Amount must be at least 0', [
84+
{ field: 'amount', code: 'min', message: 'Amount must be at least 0' },
85+
{ field: '_record', code: 'required', message: 'A contact is required' },
86+
]))(SUBMIT);
87+
expect(res.error).toBe('Amount must be at least 0');
88+
});
89+
90+
it('translates the hint through the host’s t, holes filled from its values', async () => {
91+
const t = vi.fn((key: string, englishDefault: string, values?: Record<string, string>) =>
92+
key === 'console.serverAction.fillRequiredThenRetry' ? `[${values?.fields}|${values?.action}]` : englishDefault);
93+
const handler = createConsoleServerActionHandler({
94+
fetch: refusing('Issued On is required', [{ field: 'issued_on', code: 'required', label: 'Issued On' }]),
95+
baseUrl: () => '',
96+
resolveObject: () => 'showcase_invoice',
97+
onRefresh: () => {},
98+
t,
99+
});
100+
const res = await handler(SUBMIT);
101+
expect(res.error).toBe('Issued On is required. [Issued On|Submit for Sign-off]');
102+
});
103+
});

‎ui/packages/app-shell/src/utils/consoleServerAction.ts‎

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,22 +49,29 @@ import {
4949
resolveServerActionRecordId,
5050
type ActionContext,
5151
type ActionDef,
52+
type ActionResult,
5253
type ServerActionFetch,
5354
type ServerActionHandler,
5455
type ServerActionRecordIdResolver,
5556
} from '@object-ui/core';
5657
import { takesNewTabUrlFastPath } from './newTabUrlFastPath.js';
5758

5859
/**
59-
* i18next-style translate function: `t(key, englishDefault)` returns the
60-
* translation for `key` in the active locale, or `englishDefault` when the
61-
* key is missing. Injected from a hook context (`useObjectTranslation().t`)
62-
* because this wrapper is a plain function and cannot call hooks itself.
60+
* i18next-style translate function: `t(key, englishDefault, values)` returns
61+
* the translation for `key` in the active locale, or `englishDefault` when the
62+
* key is missing, with its `{{name}}` holes filled from `values`. Injected from
63+
* a hook context (`useObjectTranslation().t`) because this wrapper is a plain
64+
* function and cannot call hooks itself.
6365
*/
64-
export type ConsoleServerActionTranslate = (key: string, englishDefault: string) => string;
66+
export type ConsoleServerActionTranslate = (
67+
key: string,
68+
englishDefault: string,
69+
values?: Record<string, string>,
70+
) => string;
6571

66-
/** No-`t` fallback (tests / standalone): every string is its English default. */
67-
const defaultTranslate: ConsoleServerActionTranslate = (_key, englishDefault) => englishDefault;
72+
/** No-`t` fallback (tests / standalone): every string is its English default, holes filled. */
73+
const defaultTranslate: ConsoleServerActionTranslate = (_key, englishDefault, values) =>
74+
values ? englishDefault.replace(/\{\{(\w+)\}\}/g, (hole, name: string) => values[name] ?? hole) : englishDefault;
6875

6976
export interface ConsoleServerActionOptions {
7077
/** Authenticated fetch wrapper (Bearer + tenant + cookies). */
@@ -102,6 +109,37 @@ export interface ConsoleServerActionOptions {
102109
navigate?: (url: string) => void;
103110
}
104111

112+
/**
113+
* A refusal that names required fields says what to do next after the
114+
* server's own sentence: fill those fields in, then run the action again.
115+
* The action route has no form to mark them on, so the hint names them.
116+
* Only `required` entries located on a field count; any other refusal, and a
117+
* record-level rule, keep the server's text alone.
118+
*/
119+
function withRequiredFieldsHint(
120+
result: ActionResult,
121+
action: ActionDef,
122+
t: ConsoleServerActionTranslate,
123+
): ActionResult {
124+
const names = [...new Set(
125+
(result.fieldErrors ?? [])
126+
.filter((entry) => entry.code === 'required' && entry.field !== '_record')
127+
.map((entry) => entry.label || entry.field),
128+
)];
129+
if (names.length === 0 || !result.error) return result;
130+
// The pack's list punctuation, the key the form's field lists join with.
131+
const fields = names.join(t('validation.formInvalidJoiner', ', '));
132+
const hint = action.label
133+
? t('console.serverAction.fillRequiredThenRetry', 'Fill in {{fields}}, then run “{{action}}” again.', { fields, action: action.label })
134+
: t('console.serverAction.fillRequiredFirst', 'Fill in {{fields}} first.', { fields });
135+
// The server's sentence ends before the hint starts: its own stop, else one
136+
// in its script, and a CJK stop takes no space after it.
137+
const error = result.error.trimEnd();
138+
const stop = /[.!?。!?]$/u.test(error) ? '' : /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]$/u.test(error) ? '。' : '.';
139+
const sentence = `${error}${stop}`;
140+
return { ...result, error: `${sentence}${/[。!?]$/u.test(sentence) ? '' : ' '}${hint}` };
141+
}
142+
105143
/** Minimal HTML escape for locale strings interpolated into the spinner document. */
106144
function escapeHtml(s: string): string {
107145
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
@@ -221,7 +259,7 @@ export function createConsoleServerActionHandler(opts: ConsoleServerActionOption
221259
closeTab(preOpenedTab);
222260
// Don't toast here — the ActionRunner's post-execution hook surfaces
223261
// `error` as a toast; toasting here too double-fires it.
224-
return result;
262+
return withRequiredFieldsHint(result, action, t);
225263
}
226264
// ── redirectUrl convention ────────────────────────────────────────
227265
// Read off the HANDLER's return value, not the action envelope wrapping

‎ui/packages/app-shell/src/views/RecordDetailView.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1829,7 +1829,8 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
18291829
// Read through the env ref so the spinner-tab / popup-blocked copy
18301830
// follows a language switch without invalidating the handler instance
18311831
// (objectui#3321).
1832-
t: (key, englishDefault) => String(serverActionEnvRef.current.t(key, englishDefault)),
1832+
t: (key, englishDefault, values) =>
1833+
String(serverActionEnvRef.current.t(key, { defaultValue: englishDefault, ...values })),
18331834
}),
18341835
[authFetch],
18351836
);
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* objectstack-ai/objectstack#27520 row 11 — a refused action keeps the
6+
* refusal's per-field entries, not only its text.
7+
*
8+
* The answer below is the one the action route gave on the showcase's draft
9+
* invoice → Submit for Sign-off (measured on a live stack): a 400
10+
* `VALIDATION_FAILED` whose `details.fields` names the field the
11+
* `requiredWhen` rule refused. The text alone reached the toast; the entries
12+
* were dropped, so the host could not say which field to fill in.
13+
*/
14+
15+
import { describe, it, expect, vi } from 'vitest';
16+
import { interpretActionResponse } from '../actionResponse';
17+
import { createServerActionHandler, type ServerActionFetch } from '../serverActionHandler';
18+
import type { ActionDef } from '../ActionRunner';
19+
20+
const SUBMIT_REFUSAL = {
21+
success: false,
22+
error: {
23+
code: 'VALIDATION_FAILED',
24+
message: 'Issued On is required',
25+
httpStatus: 400,
26+
details: { fields: [{ field: 'issued_on', code: 'required', message: 'Issued On is required', label: 'Issued On' }] },
27+
},
28+
};
29+
30+
describe('a refused action keeps its per-field entries (objectstack#27520 row 11)', () => {
31+
it('reads the entries off the answer beside its text', () => {
32+
const out = interpretActionResponse({ ok: false, status: 400 }, SUBMIT_REFUSAL, 'Action "submit"');
33+
expect(out.ok).toBe(false);
34+
expect(out.error).toBe('Issued On is required');
35+
expect(out.fieldErrors).toEqual([
36+
{ field: 'issued_on', code: 'required', message: 'Issued On is required', label: 'Issued On' },
37+
]);
38+
});
39+
40+
it('keeps the entries when the refusal also carries a producer-marked userMessage', () => {
41+
const out = interpretActionResponse({ ok: false, status: 400 }, {
42+
...SUBMIT_REFUSAL,
43+
error: { ...SUBMIT_REFUSAL.error, userMessage: 'Add the issue date, then submit again.' },
44+
}, 'Action "submit"');
45+
expect(out.ok).toBe(false);
46+
expect(out.fieldErrors).toEqual(SUBMIT_REFUSAL.error.details.fields);
47+
});
48+
49+
it('drops an entry with no field, and sets nothing when none is left', () => {
50+
const out = interpretActionResponse({ ok: false, status: 400 }, {
51+
success: false,
52+
error: { message: 'Refused', details: { fields: [{ code: 'required' }, { field: '' }] } },
53+
}, 'Action "x"');
54+
expect(out.error).toBe('Refused');
55+
expect(out).not.toHaveProperty('fieldErrors');
56+
});
57+
58+
it('reads the legacy inner envelope’s own list', () => {
59+
const out = interpretActionResponse({ ok: true, status: 200 }, {
60+
success: true,
61+
data: { success: false, error: 'Issued On is required', fields: [{ field: 'issued_on', code: 'required' }] },
62+
}, 'Action "x"');
63+
expect(out.ok).toBe(false);
64+
expect(out.fieldErrors).toEqual([{ field: 'issued_on', code: 'required' }]);
65+
});
66+
67+
it('hands the entries on through the dispatch’s failed result', async () => {
68+
const fetch = vi.fn(async () => ({ ok: false, status: 400, json: async () => SUBMIT_REFUSAL })) as unknown as ServerActionFetch;
69+
const handler = createServerActionHandler({ fetch });
70+
const res = await handler({ type: 'script', name: 'submit', objectName: 'invoice', recordId: 'inv_1' } as ActionDef);
71+
expect(res.success).toBe(false);
72+
expect(res.error).toBe('Issued On is required');
73+
expect(res.fieldErrors).toEqual([
74+
{ field: 'issued_on', code: 'required', message: 'Issued On is required', label: 'Issued On' },
75+
]);
76+
});
77+
});

‎ui/packages/core/src/actions/actionResponse.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,18 @@
4949
* failure directly.
5050
*/
5151

52+
import type { ActionFieldError } from '@object-ui/types';
5253
import { actionErrorDetail } from './actionErrorDetail.js';
5354

5455
export interface ActionResponseOutcome {
5556
ok: boolean;
5657
/** Human-readable failure text. Always a string when `ok` is false. */
5758
error?: string;
59+
/**
60+
* The refusal's per-field entries (`error.details.fields`), when it carried
61+
* any with a usable `field`. Only on a failure.
62+
*/
63+
fieldErrors?: ActionFieldError[];
5864
/**
5965
* The ACTION ENVELOPE (`body.data`) — `{ success, data }`. Kept as-is
6066
* because `ActionResult.data` has always carried this level and
@@ -119,13 +125,39 @@ export function interpretActionResponse(
119125
// `{message, code}` object, which `actionErrorDetail` resolves to a
120126
// string — handing that object to `toast.error()` as a React child
121127
// crashes the page, React #31).
128+
const fieldErrors = refusalFieldErrors(innerFailed ? envelope : json);
122129
return {
123130
ok: false,
124131
error: innerFailed
125132
? actionErrorDetail(envelope, `${label} failed`)
126133
: actionErrorDetail(json, `${label} failed (HTTP ${res.status})`),
134+
...(fieldErrors ? { fieldErrors } : {}),
127135
};
128136
}
129137

130138
return { ok: true, envelope, payload: readActionPayload(envelope) };
131139
}
140+
141+
/**
142+
* The `fields` list of a refusal body: `error.details.fields` on the current
143+
* envelope, `fields` on the legacy inner one. Entries with no string `field`
144+
* are dropped rather than guessed at; the rest keep the server's own `code`,
145+
* `message` and `label`.
146+
*/
147+
function refusalFieldErrors(body: unknown): ActionFieldError[] | undefined {
148+
const b = body as { error?: { details?: { fields?: unknown } }; fields?: unknown } | null | undefined;
149+
const raw = b?.error?.details?.fields ?? b?.fields;
150+
if (!Array.isArray(raw)) return undefined;
151+
const out: ActionFieldError[] = [];
152+
for (const entry of raw) {
153+
const e = entry as Record<string, unknown> | null;
154+
if (!e || typeof e !== 'object' || typeof e.field !== 'string' || e.field === '') continue;
155+
out.push({
156+
field: e.field,
157+
...(typeof e.code === 'string' ? { code: e.code } : {}),
158+
...(typeof e.message === 'string' ? { message: e.message } : {}),
159+
...(typeof e.label === 'string' ? { label: e.label } : {}),
160+
});
161+
}
162+
return out.length > 0 ? out : undefined;
163+
}

‎ui/packages/core/src/actions/serverActionHandler.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,11 @@ export function createServerActionHandler(config: ServerActionHandlerConfig): Se
301301
if (!outcome.ok) {
302302
// Don't toast here — the ActionRunner's post-execution hook surfaces
303303
// `error` as a toast; a handler-side toast double-fires it.
304-
return { success: false, error: outcome.error };
304+
return {
305+
success: false,
306+
error: outcome.error,
307+
...(outcome.fieldErrors ? { fieldErrors: outcome.fieldErrors } : {}),
308+
};
305309
}
306310
const shouldRefresh = action.refreshAfter !== false;
307311
if (shouldRefresh) onRefresh?.();

0 commit comments

Comments
 (0)