Skip to content

Commit 2713377

Browse files
fix(app-shell): a screen-flow result screen draws Done, and closing it completes the run (#27046)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8VWCXoujnsz5LFXGfY9fWyR)_ Fixes #26902 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:屏幕流的「结果屏」(所有字段都是只读 `record_link`、且引擎说后面没有下一屏)底部只剩一个「完成」;点「完成」、右上角 X、按 Esc、点遮罩,都会以空输入恢复运行并照 Submit 的路径收尾(成功提示、刷新数据、`onComplete`)。 - 为什么改:以前这一屏还画「取消 + 提交」,取消和关闭只关掉对话框,运行一直挂起、宿主页不刷新,刚转化的线索仍显示 Qualified,「取消」还像能撤销已写入的记录。 - 风险与回滚:只动控制台 `FlowRunner` 的底部按钮与关闭处理,以及一个新判定 `isDisplayOnlyScreen`;有输入的屏、无字段的纯消息屏、引擎说还有下一屏的只读步骤、`object-form` 步骤、失败/拒绝的终态都保持原样。回滚 = revert 本 PR。 - 先看:`ui/packages/app-shell/src/views/FlowRunner.tsx` 的 `resultScreen` / `dismiss` 两行与底部分支;`ScreenView.tsx` 的 `isDisplayOnlyScreen`;新测试 `FlowRunner.resultScreen-26902.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/__tests__/FlowRunner.resultScreen-26902.test.tsx`,预期 11 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes `FlowRunner` (the console's screen-flow dialog) now treats a **result screen** as something the user acknowledges, not cancels: - New predicate `isDisplayOnlyScreen(screen)` in `ScreenView.tsx`, beside `isRecordLinkScreenField`: a flat screen that declares at least one field, every one of them `type: 'record_link'` (matched exactly, as the engine's resume rule matches it). `record_link` is the only member of the spec's open screen-field `type` vocabulary that takes no input, so this is the whole of "a screen with no input fields". Read over the DECLARED fields, like the dialog width, so the answer never changes while the screen is open. - On such a screen, when it is the end of the line (`progress.nextScreen` is `'none'`, or the server serves no `progress`), and the run is not already terminal: the footer is one primary **Done** (the existing `common.done` row, no new i18n row), and Done, the dialog's X, Escape and an outside click all go through `submit()`: resume with no inputs, then take the answer exactly as Submit does (completion toast, `notifyDataChanged({ objectName: '*' })`, `onComplete`; the next screen if the run pauses again; the banner if the resume fails). A close while that resume is in flight is ignored, as on Submit. Once a resume has failed, closing is a plain close again (`onClose`) and Done is the retry, so an outage never traps the user in the dialog. - A new header section in `FlowRunner.tsx` records the behaviour and its bounds. Unchanged on purpose: - A screen with any input field keeps Cancel + Submit, and its Cancel and X still only close. - A screen with **no fields at all** is not display-only: a message-only step can be the go-ahead for what follows it ("Delete these records?"), so its Cancel must stay a way out. - A display-only step the engine says another screen follows (`nextScreen` `'certain'` / `'possible'`) keeps Cancel + Next: there Cancel abandons a wizard, and an X that resumed would open the next step instead of closing. - `object-form` steps and the terminal paths (refused end, terminal failure: a single Close) keep their behaviour. - `FlowRunnerProps` is unchanged, so neither launch host (`RecordDetailView`, `useConsoleActionRuntime`) changes. ## Why On the HotCRM `lead_conversion` flow, the last screen is three `record_link` fields (the created account, contact and opportunity). The dialog drew Cancel + Submit; Cancel or the close dismissed the dialog without resuming, so the run stayed suspended, `onComplete` never fired, and the record page kept showing the converted lead as Qualified until a reload. "Cancel" also read as if it could undo the conversion. The server side completes on the empty resume, so the client is not faking completion: `screen-record-link-resume.test.ts` (service-automation) resumes a record-link result screen with `variables: {}` and asserts success plus the next node's write, and `flow-screen-record-link.dogfood.test.ts` posts `inputs: {}` to the real resume door and asserts the run completes. ## Tests New file `ui/packages/app-shell/src/views/__tests__/FlowRunner.resultScreen-26902.test.tsx` (11 tests), on the `lead_conversion` shape (a last screen of three `record_link` fields): - the footer shows Done and no Cancel or Submit; - Done POSTs the resume with `inputs: {}` and calls `onComplete` once (never `onClose`), one success toast, one `'*'` data change over the real bus; - the X and Escape each POST the resume and call `onComplete` once; - a close while the resume is in flight posts nothing more; - a resume that pauses again draws the next screen (back to Cancel + Submit); - a retryable failed resume keeps the banner and Done; - after a retryable failed resume, the X and Escape call `onClose` with no second resume POST, and Done stays enabled; - controls: a screen with any input keeps Cancel + Submit and its Cancel/X only close; a message-only screen keeps its Cancel; a display-only step with another screen after it keeps Cancel + Next; - `isDisplayOnlyScreen` truth table (links only, one link, link + text, case-mismatched `Record_Link`, empty, absent `fields`, `object-form`). ## Acceptance notes - The `react-refresh/only-export-components` warning count in `ScreenView.tsx` rises by one with the new export, the same warning every other helper export in that file already carries; eslint exits 0. - Ablation (one-shot, not kept): the `dismiss` binding's result-screen arm removed, so it reads `const dismiss = onClose;` (the old close behaviour), through `scripts/ablation-replace.mjs`: the X/Escape test turns red (`onComplete` never called, 1 failed / 9 passed), then the file is restored to the HEAD blob with `git diff HEAD` empty. - Second ablation (on `00147cd2`): the dismiss guard `&& !resumeError` removed: the "after a failed resume the close only closes" test turns red (1 failed / 10 passed), then restored to the HEAD blob `6c27e23d5741`. ## Verification All readings on head `00147cd2`, base `origin/main` `cb8988ee`. <details><summary>Local test and type-check runs</summary> - `pnpm --dir ui exec vitest run packages/app-shell/src/views/__tests__/FlowRunner.resultScreen-26902.test.tsx`: Test Files 1 passed, Tests 11 passed. - 38 affected files (every `FlowRunner*`, `ScreenView*` and `ScreenPreview*` test, the flow simulator parity, `flowResponse`, `flow-envelope.ratchet`, `spec-symbol-parity`, the flow and refused-launch tests of both hosts, `FlowRunsPage`): Test Files 38 passed, Tests 372 passed. - `pnpm --filter @object-ui/app-shell run type-check`: exit 0. - `eslint --no-inline-config` on the three changed files: exit 0, 0 errors. - Ablation: `os-verify-lock: VERDICT command-exit 1`, `Tests 1 failed | 9 passed (10)`, restore `blob == HEAD (318486c)`. </details> <details><summary>Derived gates (dispatch-gates --commands, reconciled with --ran)</summary> 24 derived, 24 run, 0 NOT-MEASURED, 0 UNRUN; every one exit 0: `check-closing-keyword-parity` (+ `--self-test`), `check-comment-mask-corpus` (18647 files, 0 disagree), `check-sdui-manifest` (+ `--self-test`), `vitest run scripts/` under `ui/` (176 files passed), spec `check:duration-unit-keys`, and `check:cross-package-test-inputs`, `dispatcher-error-vocabulary`, `driver-memory-census`, `engine-double-contract`, `gitlink-declared`, `nul-bytes`, `published-files`, `query-options-erasure`, `refd-timer-probe`, `skill-identifier-liveness`, `test-source-alias`, `tier-file-adoption`, `type-check-coverage`, `type-check-debt`, `undeclared-dep-imports`, `watch-hint-literal`, `where-matcher`. Also run outside the derivation: `ui` `check:new-line-citations` (0 new) and `check:i18n-keys`, both exit 0. </details> Changeset: none. `@object-ui/app-shell` is `private: true`, so the diff publishes nothing; labelled `skip-changeset`. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (changed files linted locally; repo-wide lint is CI's) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. (publishes nothing) - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbWYBBF9CLagvsKZNkN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f86106b commit 2713377

3 files changed

Lines changed: 302 additions & 1 deletion

File tree

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

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,35 @@
276276
* resume consumed the suspension) withdraws Submit as the sections above say:
277277
* the run is over, and only a new run, or an operator's
278278
* `restore-suspension`, can accept input again.
279+
*
280+
* ## A result screen is acknowledged, never cancelled (objectstack#26902)
281+
*
282+
* A flow can end on a screen that only shows what it did: every field a
283+
* `record_link` ({@link isDisplayOnlyScreen}), such as a lead conversion's
284+
* last screen linking the account, contact and opportunity it created. That
285+
* screen used to draw Cancel + Submit, and Cancel and the dialog close only
286+
* dismissed the dialog: the run stayed suspended, `onComplete` never fired, so
287+
* the page behind still showed the lead as it was before the conversion, and
288+
* "Cancel" read as if it could undo records already written.
289+
*
290+
* So on such a screen the footer is one primary Done (`common.done`), and
291+
* Done, the close button, Escape and a click outside all do the same thing:
292+
* resume the run with no inputs (the engine refuses any value under a record
293+
* link's name, and completes on the empty resume) and take the answer exactly
294+
* as Submit does — the completion toast, `notifyDataChanged`, `onComplete`; a
295+
* further screen if the run pauses again; the banner if the resume fails.
296+
* While that resume is in flight the dialog cannot be dismissed, as on Submit.
297+
* Once a resume has failed, closing goes back to a plain close: Done stays
298+
* the retry, but an outage must never leave the user with a dialog that every
299+
* way out answers by trying the same request again.
300+
*
301+
* It applies only where the screen is the end of the line. A display-only
302+
* step the engine says another screen follows (`progress.nextScreen` other
303+
* than `'none'`) keeps Cancel + Next: there Cancel abandons a wizard, which is
304+
* still a real choice, and an X that resumed would open the next step instead
305+
* of closing. A screen with no fields at all is not display-only either (see
306+
* {@link isDisplayOnlyScreen}), and a terminal failure or a refusal keeps its
307+
* single Close.
279308
*/
280309
import { Suspense, useEffect, useState } from 'react';
281310
import {
@@ -304,6 +333,7 @@ import type { AutomationResult } from '@objectstack/spec/contracts';
304333
import { toast } from 'sonner';
305334
import {
306335
ScreenView,
336+
isDisplayOnlyScreen,
307337
isObjectFormScreen,
308338
initialScreenValues,
309339
screenFieldBoundViolations,
@@ -736,9 +766,15 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat
736766
const stepCounter = progress?.step !== undefined && progress.stepCount !== undefined
737767
? t('form.stepOf', { current: progress.step, total: progress.stepCount, defaultValue: 'Step {{current}} of {{total}}' })
738768
: undefined;
769+
// A last screen that only shows what the run did is acknowledged, not
770+
// cancelled (objectstack#26902 — see the header): Done, and every way of
771+
// closing the dialog, resumes the run exactly as Submit does — until a
772+
// resume has failed, when closing is a plain close again and Done the retry.
773+
const resultScreen = !terminal && !anotherScreen && isDisplayOnlyScreen(screen);
774+
const dismiss = resultScreen && !resumeError ? () => { void submit(); } : onClose;
739775

740776
return (
741-
<Dialog open onOpenChange={(o) => { if (!o && !submitting) onClose(); }}>
777+
<Dialog open onOpenChange={(o) => { if (!o && !submitting) dismiss(); }}>
742778
{/* Bounded on every screen (objectui#12080): a flex column at most 90vh
743779
tall, in which only the body below can give up height (`min-h-0`)
744780
and scroll, so the header and the footer always stay on screen. */}
@@ -829,6 +865,12 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat
829865
<DialogFooter>
830866
{terminal ? (
831867
<Button variant="outline" onClick={onClose}>{t('common.close', { defaultValue: 'Close' })}</Button>
868+
) : resultScreen ? (
869+
<Button onClick={submit} disabled={submitting}>
870+
{submitting
871+
? t('flowRunner.submitting', { defaultValue: 'Submitting…' })
872+
: t('common.done', { defaultValue: 'Done' })}
873+
</Button>
832874
) : (
833875
<>
834876
<Button variant="outline" onClick={onClose} disabled={submitting}>{t('common.cancel', { defaultValue: 'Cancel' })}</Button>

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,25 @@ export function isRecordLinkScreenField(field: ScreenFieldSpec): boolean {
155155
return field.type === SCREEN_FIELD_RECORD_LINK_TYPE;
156156
}
157157

158+
/**
159+
* Whether a screen only shows and collects nothing: a flat screen that declares
160+
* at least one field, every one of them a record link — the one member of the
161+
* spec's open `type` vocabulary that takes no input (objectstack#26902). It is
162+
* the result screen a flow ends on, so the runtime answers it with Done rather
163+
* than Cancel + Submit (`FlowRunner`).
164+
*
165+
* Read over the DECLARED fields, like the dialog's width, so the answer never
166+
* changes while the screen is open. Two shapes are deliberately not display-only:
167+
* - a screen with no fields at all — a message-only step can be the go-ahead for
168+
* what follows it ("Delete these records?"), so its Cancel has to stay a way out;
169+
* - an `object-form` step, which writes a record and draws its own buttons.
170+
*/
171+
export function isDisplayOnlyScreen(screen: ScreenSpec): boolean {
172+
if (isObjectFormScreen(screen)) return false;
173+
const fields = screenFields(screen);
174+
return fields.length > 0 && fields.every(isRecordLinkScreenField);
175+
}
176+
158177
/** Whether a screen field is the upload widget — the widget hint, read as the renderer reads it. */
159178
function isFileScreenField(field: ScreenFieldSpec): boolean {
160179
return (field.type || '').toLowerCase() === 'file';
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26902 — a result screen is acknowledged, never cancelled.
5+
*
6+
* A screen flow that ends on a screen of read-only `type: 'record_link'`
7+
* fields (a lead conversion's last screen, linking the account, contact and
8+
* opportunity it created) used to draw Cancel + Submit, and Cancel and the
9+
* dialog close only dismissed the dialog: the run stayed suspended and the
10+
* host was never told to refresh, so the converted lead kept reading as it did
11+
* before. On such a screen the footer is now one Done, and Done and every way
12+
* of closing resume the run with no inputs and complete it as Submit does.
13+
*
14+
* Each leg that changes is held beside one that must not: a screen with an
15+
* input keeps Cancel + Submit, a message-only screen keeps its Cancel, and a
16+
* display-only step with another screen after it keeps Cancel + Next.
17+
*/
18+
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
19+
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
20+
// Module scope, per ui/AGENTS.md 测试纪律: a record link reaches `LookupField`
21+
// through a `React.lazy` factory inside `@object-ui/fields`.
22+
import '@object-ui/fields';
23+
import { subscribeDataChanges, type DataChange } from '@object-ui/react';
24+
import { FlowRunner, type ScreenFlowState } from '../FlowRunner';
25+
import { isDisplayOnlyScreen, type ScreenSpec } from '../ScreenView';
26+
27+
const toasts = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() }));
28+
vi.mock('sonner', () => ({ toast: toasts }));
29+
30+
function fakeDataSource() {
31+
return {
32+
find: vi.fn(async () => ({ data: [] })),
33+
findOne: vi.fn(async (_object: string, id: string) => ({ id, name: id })),
34+
getObjectSchema: vi.fn(async (object: string) => ({ name: object, nameField: 'name', fields: { name: { type: 'text' } } })),
35+
};
36+
}
37+
38+
/** The HotCRM `lead_conversion` result screen: three links to the records the conversion created. */
39+
const LINKS: ScreenSpec['fields'] = [
40+
{ name: 'created_account', label: 'Account', type: 'record_link', reference: 'crm_account', defaultValue: 'acc_1' },
41+
{ name: 'created_contact', label: 'Contact', type: 'record_link', reference: 'crm_contact', defaultValue: 'con_2' },
42+
{ name: 'created_opportunity', label: 'Opportunity', type: 'record_link', reference: 'crm_opportunity', defaultValue: 'opp_3' },
43+
];
44+
45+
function state(screenSpec: Partial<ScreenSpec> & { fields?: ScreenSpec['fields'] }): ScreenFlowState {
46+
return {
47+
flowName: 'lead_conversion',
48+
runId: 'run-7',
49+
screen: { nodeId: 'conversion_result', title: 'Lead converted', ...screenSpec },
50+
};
51+
}
52+
53+
const RESULT = state({ fields: LINKS, progress: { nextScreen: 'none', step: 2, stepCount: 2 } });
54+
55+
function jsonResponse(body: unknown, status = 200): Response {
56+
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
57+
}
58+
59+
const completedResume = () =>
60+
vi.fn(async (_url: string, _init?: RequestInit) => jsonResponse({ success: true, data: { success: true } }));
61+
62+
function setup(flow: ScreenFlowState, authFetch = completedResume()) {
63+
const onClose = vi.fn();
64+
const onComplete = vi.fn();
65+
render(
66+
<FlowRunner
67+
state={flow}
68+
authFetch={authFetch}
69+
baseUrl=""
70+
onClose={onClose}
71+
onComplete={onComplete}
72+
dataSource={fakeDataSource()}
73+
objects={[{ name: 'crm_account' }, { name: 'crm_contact' }, { name: 'crm_opportunity' }]}
74+
/>,
75+
);
76+
return { authFetch, onClose, onComplete };
77+
}
78+
79+
/** Radix's own dismiss `X` — its accessible name is an `sr-only` "Close". */
80+
function dismissX(): HTMLElement {
81+
const x = screen.getAllByRole('button', { name: 'Close' }).find((b) => b.querySelector('.sr-only') !== null);
82+
expect(x, 'the dialog draws its dismiss X').toBeDefined();
83+
return x as HTMLElement;
84+
}
85+
86+
/** The resume POSTs `authFetch` received, each as `{ url, inputs }`. */
87+
function resumes(authFetch: ReturnType<typeof completedResume>) {
88+
return authFetch.mock.calls.map(([url, init]) => ({ url, inputs: JSON.parse(String(init?.body)).inputs }));
89+
}
90+
91+
const RESUME_URL = '/api/v1/automation/lead_conversion/runs/run-7/resume';
92+
93+
let changes: DataChange[] = [];
94+
let unsubscribe: () => void = () => {};
95+
beforeEach(() => {
96+
toasts.error.mockReset();
97+
toasts.success.mockReset();
98+
changes = [];
99+
unsubscribe = subscribeDataChanges((change) => changes.push(change));
100+
});
101+
afterEach(() => {
102+
unsubscribe();
103+
cleanup();
104+
});
105+
106+
describe('objectstack#26902 — a result screen is acknowledged, never cancelled', () => {
107+
it('draws one Done on the lead_conversion result screen, and no Cancel or Submit', async () => {
108+
setup(RESULT);
109+
expect(await screen.findByRole('button', { name: 'Done' })).toBeEnabled();
110+
expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
111+
expect(screen.queryByRole('button', { name: 'Submit' })).toBeNull();
112+
});
113+
114+
it('Done resumes the run with no inputs and completes it once, as Submit does', async () => {
115+
const { authFetch, onClose, onComplete } = setup(RESULT);
116+
fireEvent.click(await screen.findByRole('button', { name: 'Done' }));
117+
await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1));
118+
expect(resumes(authFetch)).toEqual([{ url: RESUME_URL, inputs: {} }]);
119+
expect(onClose).not.toHaveBeenCalled();
120+
expect(toasts.success).toHaveBeenCalledTimes(1);
121+
expect(changes).toEqual([expect.objectContaining({ objectName: '*' })]);
122+
});
123+
124+
it('the dialog close resumes and completes the run too — the X, and Escape', async () => {
125+
const x = setup(RESULT);
126+
fireEvent.click(dismissX());
127+
await waitFor(() => expect(x.onComplete).toHaveBeenCalledTimes(1));
128+
expect(resumes(x.authFetch)).toEqual([{ url: RESUME_URL, inputs: {} }]);
129+
expect(x.onClose).not.toHaveBeenCalled();
130+
cleanup();
131+
132+
const esc = setup(RESULT);
133+
fireEvent.keyDown(await screen.findByRole('dialog'), { key: 'Escape' });
134+
await waitFor(() => expect(esc.onComplete).toHaveBeenCalledTimes(1));
135+
expect(resumes(esc.authFetch)).toEqual([{ url: RESUME_URL, inputs: {} }]);
136+
expect(esc.onClose).not.toHaveBeenCalled();
137+
});
138+
139+
it('a close while the resume is in flight posts nothing more', async () => {
140+
let answer: (r: Response) => void = () => {};
141+
const authFetch = vi.fn(() => new Promise<Response>((resolve) => { answer = resolve; }));
142+
const { onComplete } = setup(RESULT, authFetch as unknown as ReturnType<typeof completedResume>);
143+
fireEvent.click(await screen.findByRole('button', { name: 'Done' }));
144+
await waitFor(() => expect(authFetch).toHaveBeenCalledTimes(1));
145+
fireEvent.click(dismissX());
146+
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });
147+
answer(jsonResponse({ success: true, data: { success: true } }));
148+
await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1));
149+
expect(authFetch).toHaveBeenCalledTimes(1);
150+
});
151+
152+
it('a resume that pauses again draws the next screen, as Submit does', async () => {
153+
const authFetch = vi.fn(async () =>
154+
jsonResponse({
155+
success: true,
156+
data: { status: 'paused', runId: 'run-7', screen: { nodeId: 'follow_up', title: 'Schedule a follow-up', fields: [{ name: 'when', label: 'When', type: 'text' }] } },
157+
}),
158+
);
159+
const { onClose, onComplete } = setup(state({ fields: LINKS }), authFetch as unknown as ReturnType<typeof completedResume>);
160+
fireEvent.click(await screen.findByRole('button', { name: 'Done' }));
161+
expect(await screen.findByText('Schedule a follow-up')).toBeInTheDocument();
162+
// The next screen takes input, so it is back to Cancel + Submit.
163+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
164+
expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument();
165+
expect(onComplete).not.toHaveBeenCalled();
166+
expect(onClose).not.toHaveBeenCalled();
167+
});
168+
169+
it('a failed resume keeps the screen and its banner, and a retryable one keeps Done', async () => {
170+
const authFetch = vi.fn(async () => jsonResponse({ success: false, error: 'gateway timeout' }, 502));
171+
const { onClose, onComplete } = setup(RESULT, authFetch as unknown as ReturnType<typeof completedResume>);
172+
fireEvent.click(await screen.findByRole('button', { name: 'Done' }));
173+
await waitFor(() => expect(toasts.error).toHaveBeenCalledTimes(1));
174+
expect(screen.getByRole('alert')).toBeInTheDocument();
175+
expect(screen.getByRole('button', { name: 'Done' })).toBeEnabled();
176+
expect(onComplete).not.toHaveBeenCalled();
177+
expect(onClose).not.toHaveBeenCalled();
178+
});
179+
180+
it('after a failed resume the close only closes: an outage never traps the user in the dialog', async () => {
181+
const authFetch = vi.fn(async () => jsonResponse({ success: false, error: 'gateway timeout' }, 502));
182+
const { onClose, onComplete } = setup(RESULT, authFetch as unknown as ReturnType<typeof completedResume>);
183+
fireEvent.click(await screen.findByRole('button', { name: 'Done' }));
184+
await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument());
185+
expect(authFetch).toHaveBeenCalledTimes(1);
186+
fireEvent.click(dismissX());
187+
expect(onClose).toHaveBeenCalledTimes(1);
188+
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });
189+
expect(onClose).toHaveBeenCalledTimes(2);
190+
// Neither close resumed again; Done is still the retry.
191+
expect(authFetch).toHaveBeenCalledTimes(1);
192+
expect(screen.getByRole('button', { name: 'Done' })).toBeEnabled();
193+
expect(onComplete).not.toHaveBeenCalled();
194+
});
195+
196+
it('a screen with any input field keeps Cancel + Submit, and its Cancel only closes', async () => {
197+
const { authFetch, onClose, onComplete } = setup(
198+
state({ fields: [...LINKS!, { name: 'note', label: 'Note', type: 'text' }], progress: { nextScreen: 'none' } }),
199+
);
200+
expect(await screen.findByRole('button', { name: 'Submit' })).toBeInTheDocument();
201+
expect(screen.queryByRole('button', { name: 'Done' })).toBeNull();
202+
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
203+
expect(onClose).toHaveBeenCalledTimes(1);
204+
fireEvent.click(dismissX());
205+
expect(onClose).toHaveBeenCalledTimes(2);
206+
expect(authFetch).not.toHaveBeenCalled();
207+
expect(onComplete).not.toHaveBeenCalled();
208+
});
209+
210+
it('a message-only screen keeps its Cancel: it may be the go-ahead for what follows', async () => {
211+
const { authFetch, onClose } = setup(state({ description: 'Delete these records?' }));
212+
expect(await screen.findByRole('button', { name: 'Cancel' })).toBeInTheDocument();
213+
expect(screen.queryByRole('button', { name: 'Done' })).toBeNull();
214+
fireEvent.click(dismissX());
215+
expect(onClose).toHaveBeenCalledTimes(1);
216+
expect(authFetch).not.toHaveBeenCalled();
217+
});
218+
219+
it('a display-only step with another screen after it keeps Cancel + Next', async () => {
220+
const { authFetch, onClose } = setup(state({ fields: LINKS, progress: { nextScreen: 'certain', step: 2, stepCount: 3 } }));
221+
expect(await screen.findByRole('button', { name: 'Next' })).toBeInTheDocument();
222+
expect(screen.queryByRole('button', { name: 'Done' })).toBeNull();
223+
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
224+
expect(onClose).toHaveBeenCalledTimes(1);
225+
expect(authFetch).not.toHaveBeenCalled();
226+
});
227+
});
228+
229+
describe('isDisplayOnlyScreen', () => {
230+
it('holds only for a flat screen of one or more record links', () => {
231+
expect(isDisplayOnlyScreen({ nodeId: 'r', fields: LINKS })).toBe(true);
232+
expect(isDisplayOnlyScreen({ nodeId: 'r', fields: [LINKS![0]] })).toBe(true);
233+
expect(isDisplayOnlyScreen({ nodeId: 'r', fields: [...LINKS!, { name: 'n', type: 'text' }] })).toBe(false);
234+
// Matched exactly, as the engine's resume rule matches the member.
235+
expect(isDisplayOnlyScreen({ nodeId: 'r', fields: [{ ...LINKS![0], type: 'Record_Link' }] })).toBe(false);
236+
expect(isDisplayOnlyScreen({ nodeId: 'r', fields: [] })).toBe(false);
237+
expect(isDisplayOnlyScreen({ nodeId: 'r' })).toBe(false);
238+
expect(isDisplayOnlyScreen({ nodeId: 'r', kind: 'object-form', objectName: 'crm_account', fields: LINKS })).toBe(false);
239+
});
240+
});

0 commit comments

Comments
 (0)