Skip to content

Commit 3217fc5

Browse files
fix(app-shell): refuse an empty required screen-flow field at the field, as the record form does (#26956)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbRfsb7WKBerWxaiMLy1ThAf)_ Fixes #26882 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:流程弹窗(screen flow)里必填字段留空点「提交」,以前只弹一个转瞬即逝的提示,弹窗里什么都没有,看起来像按钮坏了。现在每个漏填的字段下方直接显示「某某 is required」,输入框标红(`aria-invalid`),光标跳到第一个漏填的字段,「提交」按钮保留,不发请求、不弹 toast,与记录表单一致。 - **为什么改**:HotCRM 全量浏览器走查(hotcrm#2320,D1)报告「Schedule Follow-up 的 Due Date 留空提交毫无反应」。实测:请求没有发出,唯一的反馈是 toast,字段上没有任何标记。 - **风险与回滚**:只改控制台流程弹窗的前端呈现,不动服务端、spec 与流程定义。回滚即 revert 本 PR。卡里的第二半(引擎拒绝后「提交」消失)经实测是运行已结束的正确处置,前端无法重提,修法在流程作者侧,见 Acceptance notes。 - **先看**:`ui/packages/app-shell/src/views/FlowRunner.tsx` 的 `submit`(必填检查改为字段级);`ScreenView.tsx` 的 `errors` 属性与字段下方的消息;新测试 `FlowRunner.requiredAtField-26882.test.tsx`。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/app-shell/src/views/__tests__/FlowRunner.requiredAtField-26882.test.tsx`,预期 5 个用例全绿。 - **需要你做的**:无,席位落地。 ## What changes - **An empty required field is refused at the field.** `FlowRunner`'s `submit` used to answer a missing required field with one `toast.error` (`wizard.missingRequired`) and nothing in the dialog. It now records a per-field message (`validation.required`, "Due Date is required", in the display locale and naming the localized label), moves focus to the first missing field in screen order, and returns without a request. No toast: the record form raises none for a client-side refusal either, because each refused field carries its own message. Submit stays. Editing a field retires its own message. - **`ScreenView` draws the message.** A new optional `errors` prop (field name to message) draws the message under the control the way the record form's `FormMessage` does, and marks the control: `aria-invalid` on the plain input, textarea and select trigger, and the published `error` slot on the platform widgets (date and date-time pickers, lookup, upload, checkbox), which set `aria-invalid` on their own control. The control's `aria-describedby` names the message after any help text. The Studio preview passes no `errors` and renders as before. - **A retryable engine refusal is unchanged and now pinned for values.** `INVALID_SCREEN_INPUT` keeps the screen, the typed values and Submit, with the sentence in the banner; the resubmit goes to the same run. - Two existing suites that pinned the old toast (`FlowRunner.fileField-25801`, `FlowRunner.flowsTranslation-5920`) now read the message drawn at the field and assert no toast. The `en` locale comment that named `wizard.missingRequired` as the runner's borrowed key now names `validation.required`; no catalogue row changes. ## Tests - New `FlowRunner.requiredAtField-26882.test.tsx` (5 cases): an empty required date (message under it, `aria-invalid`, described by it, focused, Submit enabled, no request, no toast); several missing fields each with a message and focus on the first; answering a field retires its message and the next Submit resumes; the message in `zh` names the label; a retryable `INVALID_SCREEN_INPUT` keeps both typed values and Submit, and the resubmit reaches the same run. - Reverse verification: with `FlowRunner.tsx` and `ScreenView.tsx` checked out from `7a2818c5`, 4 of the 5 fail, each naming the missing sentence (`Unable to find an element with the text: Due Date is required`); the retryable case passes there too, as expected, since that behaviour already existed. Restored from `HEAD`, proved by an empty `git diff HEAD` and equal blob hashes. ## Acceptance notes - **Why QA saw "nothing happens" (measured on `7a2818c5`, jsdom, the card's screen shape):** Submit with Due Date empty made 0 resume requests, raised the toast "Please complete the required fields: Due Date", and left 0 elements with `aria-invalid`. So the untouched date was empty to the check (the PM's alternative, a non-empty untouched date, does not hold); the refusal's only carrier was a transient toast outside the dialog. - **The vanished Submit is the terminal disposition, and the ui cannot change it.** Submit is withdrawn only when the run is over: a `refused` end, or a resume answered `400 FLOW_FAILED` / `404` / a failed run under `200`. On those, the engine has already consumed the suspension: `resumeInternal` calls `forgetSuspendedRun` before it traverses the downstream nodes (an order ruled for exactly-once, per the comment above `stepCountAtPause` in `service-automation`'s `run-resume.ts`), and the only door that re-arms it, `POST /automation/FLOW/runs/RUN/restore-suspension`, is an operator verb. Which of the two terminal paths HotCRM's Generate Quote takes for "Discount cannot exceed 60%" is NOT MEASURED: the HotCRM flow is outside this repository. Either way the fix is on the flow's side: declaring the bound on the screen field (`max: 60` on the discount field) makes the engine refuse it as `INVALID_SCREEN_INPUT` before it consumes the suspension (retryable: values and Submit kept, this PR's last test), and the dialog refuses it before any request. - Out of scope, noted: the `min` / `max` bound refusal is still a toast only (`screenFieldBoundViolations`). Moving it to the field is the same mechanism (an entry in `errors`); it is left as an open question to the seat rather than changed here. ## Verification Head of the measured tree: see the final report on the card. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2fe4a74 commit 3217fc5

7 files changed

Lines changed: 340 additions & 19 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): the screen-flow dialog refuses an empty required field at the field, as the record form does
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Submit on a flow screen with a required field left empty used to answer with one toast naming the fields and nothing in the dialog, so it read as a button that did nothing. Each missing field now shows "FIELD is required" under its control, the control is marked `aria-invalid` and names the message in `aria-describedby`, focus moves to the first missing field, and Submit stays. No toast is raised and no resume request is made, matching the record form. Editing a field clears its message. A retryable refusal from the engine keeps the screen, the typed values and Submit as before; a run the engine has already ended still offers only Close.

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

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,31 @@
251251
* exactly as before.
252252
* The `object-form` step's own button (`flowRunner.saveAndContinue`) is the
253253
* form's, and is not relabelled here.
254+
*
255+
* ## An empty required field is refused AT the field (objectstack#26882)
256+
*
257+
* Submit with a required field left empty used to answer with one toast
258+
* naming the fields and nothing else: no message at the field, no
259+
* `aria-invalid`, focus left on Submit. A toast is transient and sits apart
260+
* from the dialog, so the refusal read as a button that did nothing. The
261+
* dialog now answers the way the record form does, and only that way: each
262+
* missing field shows `validation.required` ("Due Date is required") under its
263+
* control, the control is marked `aria-invalid` and names the message in its
264+
* `aria-describedby` ({@link ScreenView}), and focus moves to the first missing
265+
* field in screen order. There is no toast — the record form raises none for a
266+
* client-side refusal either, since every field it refuses carries its own
267+
* message. Submit stays where it was, and no resume request is made. Editing a
268+
* field retires its own message; the next Submit checks again.
269+
*
270+
* What this does NOT cover, on purpose: a refusal that comes back from the
271+
* ENGINE. A retryable one (`INVALID_SCREEN_INPUT`, the declared field
272+
* contract, checked before the suspension is consumed) keeps the screen, the
273+
* values and Submit, with the sentence in the banner above — the envelope
274+
* names no field in a structured slot, so placing it at a field would mean
275+
* parsing prose. A terminal one (a `refused` end, or a node failing after the
276+
* resume consumed the suspension) withdraws Submit as the sections above say:
277+
* the run is over, and only a new run, or an operator's
278+
* `restore-suspension`, can accept input again.
254279
*/
255280
import { Suspense, useEffect, useState } from 'react';
256281
import {
@@ -282,6 +307,7 @@ import {
282307
isObjectFormScreen,
283308
initialScreenValues,
284309
screenFieldBoundViolations,
310+
screenFieldControlId,
285311
screenFields,
286312
screenResumeValues,
287313
visibleScreenFields,
@@ -471,6 +497,9 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat
471497
const [submitting, setSubmitting] = useState(false);
472498
const [resumeError, setResumeError] = useState<ResumeError | null>(null);
473499
const [refused, setRefused] = useState<RefusedOutcome | null>(null);
500+
// The message each field the last Submit refused is drawn with, by field
501+
// name (objectstack#26882 — see the header).
502+
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
474503
// Every upload widget in the screen body reports into this scope, so Submit
475504
// can wait for a file field's id (objectstack#25801 — see the header).
476505
const uploads = useUploadingScope();
@@ -485,6 +514,7 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat
485514
// A fresh run must not open under the previous run's refusal.
486515
setResumeError(null);
487516
setRefused(null);
517+
setFieldErrors({});
488518
}
489519
}, [state]);
490520

@@ -495,7 +525,7 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat
495525
// it follows the live resource tree and a language switch with no identity
496526
// to keep stable (AGENTS.md §5 #10). `screen` stays the payload the run is
497527
// driven by; `shown` differs from it in copy only, so every DISPLAY read —
498-
// including the names in the missing-fields toast below — goes through it.
528+
// including the labels the required refusal below names — goes through it.
499529
const bundle = activeFlowsBundle(i18n, language, flowName);
500530
const shown = localizeScreen(screen, flowName, bundle, language);
501531
// The flow's name as the user reads it (objectui#11092), from the same bundle.
@@ -507,6 +537,14 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat
507537
// where a retry exists. A terminal refusal must stay on screen: its whole
508538
// job is to explain why the (now absent) Submit is not coming back.
509539
setResumeError((e) => (e && e.retryable ? null : e));
540+
// The field's own refusal goes with the edit that answers it; the others
541+
// stay until they are answered too.
542+
setFieldErrors((p) => {
543+
if (!(name in p)) return p;
544+
const next = { ...p };
545+
delete next[name];
546+
return next;
547+
});
510548
};
511549

512550
// Resume the paused run with `inputs` (applied as bare flow variables) and
@@ -561,6 +599,7 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat
561599
setRunId(outcome.runId || runId);
562600
setFlowLabel(outcome.flowLabel);
563601
setValues(initialScreenValues(outcome.screen));
602+
setFieldErrors({});
564603
// Only a step that saved a record says so; a flat step's answer is the
565604
// next screen itself (objectstack#26578 — see the header).
566605
if (savedRecord) toast.success(t('flowRunner.nextStep', { defaultValue: 'Saved — next step' }));
@@ -601,15 +640,26 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat
601640
const missing = visibleScreenFields(shown, values).filter(
602641
(f) => f.required && (values[f.name] === undefined || values[f.name] === '' || values[f.name] === null),
603642
);
643+
// Refused at each field, as the record form refuses it, with no toast and
644+
// no request (objectstack#26882 — see the header). The names come from
645+
// `shown`, so the message reads the field's label in the user's language.
604646
if (missing.length) {
605-
toast.error(
606-
t('wizard.missingRequired', {
607-
fields: missing.map((f) => f.label || f.name).join(', '),
608-
defaultValue: 'Please complete the required fields: {{fields}}',
609-
}),
647+
setFieldErrors(
648+
Object.fromEntries(
649+
missing.map((f) => [
650+
f.name,
651+
t('validation.required', { field: f.label || f.name, defaultValue: '{{field}} is required' }),
652+
]),
653+
),
610654
);
655+
// The first missing field in screen order takes focus, so the refusal is
656+
// where the user is looking, on a long screen too.
657+
const first = document.getElementById(screenFieldControlId(missing[0].name));
658+
first?.focus({ preventScroll: true });
659+
first?.scrollIntoView?.({ block: 'center' });
611660
return;
612661
}
662+
setFieldErrors({});
613663
// The declared `min` / `max` (objectstack#17306), refused here naming the
614664
// field, beside `required` and over the same on-screen list. A courtesy,
615665
// not the enforcement: the engine re-checks the bounds on resume, and
@@ -748,6 +798,7 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat
748798
screen={shown}
749799
values={values}
750800
onValueChange={setVal}
801+
errors={fieldErrors}
751802
dataSource={dataSource}
752803
objects={objects}
753804
// Two columns from `sm` up; `space-y-0` replaces the body's own

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

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,18 @@
8484
* form's `BooleanField` in its checkbox face, with the label BESIDE the control
8585
* and the help text under the row. The generic layout put the label on one line
8686
* and a lone checkbox on the next, which read as an empty radio button.
87+
*
88+
* ## A refused field says so under its control (objectstack#26882)
89+
*
90+
* `errors` maps a field name to the message the caller refused it with (the
91+
* runtime's `required` check). The message is drawn under the control as the
92+
* record form's `FormMessage` draws it, and the control is marked the way the
93+
* record form marks it: `aria-invalid`, and the message's id in its
94+
* `aria-describedby` beside any help text. A platform widget (picker, upload,
95+
* lookup, checkbox) receives the message as its published `error` slot, which
96+
* it reads only to set `aria-invalid` on its own control; the text stays here,
97+
* so it is drawn once. A field absent from `errors` renders as before, and the
98+
* Studio preview passes none.
8799
*/
88100
import { Suspense } from 'react';
89101
import { Link, useInRouterContext, useParams } from 'react-router-dom';
@@ -343,6 +355,19 @@ function screenFieldHelpId(name: string): string {
343355
return `ff-${name}-help`;
344356
}
345357

358+
/** The id of a screen field's refusal message, which its control names in `aria-describedby`. */
359+
function screenFieldErrorId(name: string): string {
360+
return `ff-${name}-error`;
361+
}
362+
363+
/**
364+
* The id of the control a screen field draws, the one its label points at.
365+
* Exported so the runtime can move focus to a refused field (objectstack#26882).
366+
*/
367+
export function screenFieldControlId(name: string): string {
368+
return `ff-${name}`;
369+
}
370+
346371
/** Submit/cancel wiring for the object-form body — runtime persists & resumes;
347372
* the design preview hides the bar (`showSubmit`/`showCancel` false). */
348373
export interface ScreenObjectFormActions {
@@ -372,10 +397,15 @@ export interface ScreenViewProps {
372397
*/
373398
objects?: any[];
374399
objectForm?: ScreenObjectFormActions;
400+
/**
401+
* The message each refused field is drawn with, by field name (see the
402+
* header, objectstack#26882). Absent, or a field not in it, draws no message.
403+
*/
404+
errors?: Readonly<Record<string, string>>;
375405
className?: string;
376406
}
377407

378-
export function ScreenView({ screen, values, onValueChange, dataSource, objects, objectForm, className }: ScreenViewProps) {
408+
export function ScreenView({ screen, values, onValueChange, dataSource, objects, objectForm, errors, className }: ScreenViewProps) {
379409
if (isObjectFormScreen(screen)) {
380410
const objectDef = Array.isArray(objects) ? objects.find((o: any) => o?.name === screen.objectName) : undefined;
381411
const subforms = objectDef
@@ -422,16 +452,25 @@ export function ScreenView({ screen, values, onValueChange, dataSource, objects,
422452
// placeholder (which the browser clears on the first keystroke), and
423453
// the control names it in `aria-describedby` so it is announced too.
424454
const helpId = f.inlineHelpText ? screenFieldHelpId(f.name) : undefined;
455+
// A refused field names its message too, after the help text, as the
456+
// record form's control does (objectstack#26882).
457+
const error = errors?.[f.name] || undefined;
458+
const errorId = error ? screenFieldErrorId(f.name) : undefined;
459+
const describedBy = [helpId, errorId].filter(Boolean).join(' ') || undefined;
425460
const input = (
426461
<ScreenFieldInput
427462
field={f}
428463
value={values[f.name]}
429464
onChange={(v) => onValueChange(f.name, v)}
430-
describedBy={helpId}
465+
describedBy={describedBy}
466+
error={error}
431467
dataSource={dataSource}
432468
objects={objects}
433469
/>
434470
);
471+
const message = errorId && (
472+
<p id={errorId} className="text-sm font-medium text-destructive" data-screen-field-error={f.name}>{error}</p>
473+
);
435474
// The boolean control sits BESIDE its label, `ActionParamDialog`'s
436475
// inline row; the help text lines up under the label.
437476
if (isBooleanScreenField(f)) {
@@ -449,6 +488,7 @@ export function ScreenView({ screen, values, onValueChange, dataSource, objects,
449488
{helpId && (
450489
<p id={helpId} className="text-xs text-muted-foreground ml-6">{f.inlineHelpText}</p>
451490
)}
491+
{message && <div className="ml-6">{message}</div>}
452492
</div>
453493
);
454494
}
@@ -467,6 +507,7 @@ export function ScreenView({ screen, values, onValueChange, dataSource, objects,
467507
{helpId && (
468508
<p id={helpId} className="text-xs text-muted-foreground">{f.inlineHelpText}</p>
469509
)}
510+
{message}
470511
</div>
471512
);
472513
})}
@@ -489,21 +530,29 @@ export function ScreenFieldInput({
489530
value,
490531
onChange,
491532
describedBy,
533+
error,
492534
dataSource,
493535
objects,
494536
}: {
495537
field: ScreenFieldSpec;
496538
value: unknown;
497539
onChange: (v: unknown) => void;
498-
/** Id of the element describing this control (its help text), for `aria-describedby`. */
540+
/** Ids of the elements describing this control (its help text, its refusal), for `aria-describedby`. */
499541
describedBy?: string;
542+
/**
543+
* The message this field was refused with (objectstack#26882). A platform
544+
* widget takes it as its `error` slot, which sets `aria-invalid` on its own
545+
* control; a plain control gets `aria-invalid` here. The text itself is
546+
* drawn by {@link ScreenView}.
547+
*/
548+
error?: string;
500549
/** Data source a `type: 'lookup'` picker queries its `reference` object through. */
501550
dataSource?: unknown;
502551
/** The console's object definitions: the objects a `record_link` field may link to a page of. */
503552
objects?: ReadonlyArray<unknown>;
504553
}) {
505554
const { t } = useObjectTranslation();
506-
const id = `ff-${field.name}`;
555+
const id = screenFieldControlId(field.name);
507556
const kind = (field.type || 'text').toLowerCase();
508557
// The required STATE, announced on the control itself — the label's `*` is
509558
// visual-only (objectui#10367). `aria-required`, not native `required`: the
@@ -512,6 +561,8 @@ export function ScreenFieldInput({
512561
// browser's own verdict beside it — on a checkbox it means "must be checked".
513562
// `undefined` when optional, so the attribute is omitted rather than "false".
514563
const ariaRequired = field.required ? true : undefined;
564+
// The refused STATE, for the plain controls below; omitted when not refused.
565+
const ariaInvalid = error ? true : undefined;
515566

516567
// The read-only record link (objectstack#25872): the record's display name,
517568
// drawn by the lookup widget's `readonly` branch, inside a link to its page.
@@ -538,6 +589,7 @@ export function ScreenFieldInput({
538589
onChange={onChange}
539590
field={{ name: field.name, label: field.label, type: 'lookup', reference: field.reference, placeholder: field.placeholder }}
540591
dataSource={dataSource}
592+
error={error}
541593
aria-required={ariaRequired}
542594
aria-describedby={describedBy}
543595
/>
@@ -558,6 +610,7 @@ export function ScreenFieldInput({
558610
value={value ?? null}
559611
onChange={onChange}
560612
field={{ name: field.name, label: field.label, type: 'file', placeholder: field.placeholder }}
613+
error={error}
561614
aria-required={ariaRequired}
562615
aria-describedby={describedBy}
563616
/>
@@ -583,7 +636,7 @@ export function ScreenFieldInput({
583636
};
584637
return (
585638
<Select value={value != null ? flowScreenFieldOptionKey(value) : undefined} onValueChange={pickOption}>
586-
<SelectTrigger id={id} aria-required={ariaRequired} aria-describedby={describedBy}><SelectValue placeholder={placeholder} /></SelectTrigger>
639+
<SelectTrigger id={id} aria-required={ariaRequired} aria-invalid={ariaInvalid} aria-describedby={describedBy}><SelectValue placeholder={placeholder} /></SelectTrigger>
587640
<SelectContent>
588641
{options.map((o, i) => (
589642
<SelectItem key={i} value={flowScreenFieldOptionKey(o.value)}>{o.label}</SelectItem>
@@ -606,6 +659,7 @@ export function ScreenFieldInput({
606659
onChange={(checked: unknown) => onChange(checked === true)}
607660
field={{ name: field.name, label: field.label, type: 'boolean', widget: 'checkbox' }}
608661
className="mt-0.5"
662+
error={error}
609663
aria-required={ariaRequired}
610664
aria-describedby={describedBy}
611665
/>
@@ -624,14 +678,15 @@ export function ScreenFieldInput({
624678
value={value ?? null}
625679
onChange={onChange}
626680
field={{ name: field.name, label: field.label, type: kind }}
681+
error={error}
627682
aria-required={ariaRequired}
628683
aria-describedby={describedBy}
629684
/>
630685
</Suspense>
631686
);
632687
}
633688
if (kind === 'textarea' || kind === 'markdown') {
634-
return <Textarea id={id} aria-required={ariaRequired} aria-describedby={describedBy} value={(value as string) ?? ''} placeholder={field.placeholder} onChange={(e) => onChange(e.target.value)} />;
689+
return <Textarea id={id} aria-required={ariaRequired} aria-invalid={ariaInvalid} aria-describedby={describedBy} value={(value as string) ?? ''} placeholder={field.placeholder} onChange={(e) => onChange(e.target.value)} />;
635690
}
636691
const htmlType = kind === 'number' || kind === 'currency' ? 'number' : kind === 'email' ? 'email' : 'text';
637692
// The declared bound pair goes on a NUMERIC input only, as the native
@@ -642,6 +697,7 @@ export function ScreenFieldInput({
642697
<Input
643698
id={id}
644699
aria-required={ariaRequired}
700+
aria-invalid={ariaInvalid}
645701
aria-describedby={describedBy}
646702
type={htmlType}
647703
min={numeric ? field.min : undefined}

‎ui/packages/app-shell/src/views/__tests__/FlowRunner.fileField-25801.test.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,10 @@ describe('objectstack#25801 — a `type: \'file\'` screen field is the platform
136136

137137
fireEvent.click(screen.getByRole('button', { name: 'Submit' }));
138138

139-
await waitFor(() => expect(toasts.error).toHaveBeenCalledTimes(1));
140-
expect(String(toasts.error.mock.calls[0][0])).toContain('Contract');
139+
// Refused at the field (objectstack#26882), naming it, with no toast.
140+
await waitFor(() => expect(document.querySelector('[data-screen-field-error]')).not.toBeNull());
141+
expect(document.querySelector('[data-screen-field-error]')?.textContent).toContain('Contract');
142+
expect(toasts.error).not.toHaveBeenCalled();
141143
expect(authFetch).not.toHaveBeenCalled();
142144
});
143145

‎ui/packages/app-shell/src/views/__tests__/FlowRunner.flowsTranslation-5920.test.tsx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ describe('FlowRunner — the `flows` translation overlay (objectui#5920)', () =>
136136
expect(dialog.queryByPlaceholderText('e.g. Annual renewal')).not.toBeInTheDocument();
137137
});
138138

139-
it('names the translated label in the missing-required toast, not the authored one', async () => {
139+
it('names the translated label in the missing-required refusal, not the authored one', async () => {
140140
const user = userEvent.setup();
141141
const instance = i18nWith('zh-CN', 'zh-CN', ZH_CN);
142142
renderRunner(CONVERSION, instance);
@@ -146,8 +146,10 @@ describe('FlowRunner — the `flows` translation overlay (objectui#5920)', () =>
146146
// than spelled here.
147147
await user.click(screen.getByRole('button', { name: instance.t('common.submit') }));
148148

149-
await waitFor(() => expect(toasts.error).toHaveBeenCalledTimes(1));
150-
const message = String(toasts.error.mock.calls[0][0]);
149+
// The refusal is drawn at the field (objectstack#26882), not toasted.
150+
await waitFor(() => expect(document.querySelector('[data-screen-field-error]')).not.toBeNull());
151+
expect(toasts.error).not.toHaveBeenCalled();
152+
const message = String(document.querySelector('[data-screen-field-error]')?.textContent);
151153
expect(message).toContain('商机名称');
152154
expect(message).not.toContain('Opportunity Name');
153155
});

0 commit comments

Comments
 (0)