Skip to content

Commit 121d1a5

Browse files
fix(console): a record save whose answer carries warnings shows one toast, the record-named save message carrying the warning lines (#28114)
domain:ui#4 · os-bill · session_01Utp6CbDAucNyQ4UtzCJKyh <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgDFH4S5pR1C8YvYCWWsZfXx)_ Fixes #27999 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:记录表单(弹窗、子表任务、整页表单)保存时,若答复带有 `severity: 'warning'` 校验规则的提示,现在只弹一条提示:带记录名的保存提示(如 `Task "Quarterly review" updated`),警告级样式,每条规则提示单独一行。 - **为什么改**:之前同一次保存弹两条:写入警告通道先弹「Saved — with warnings」,表单随后再弹带记录名的保存成功提示,用户看到两条说法不同的消息。 - **风险与回滚**:只动 `@object-ui/app-shell` 三个文件。表单打开期间,写入警告通道把「这张表单的那次写入」的答复警告交给表单,不再自己弹;字段被剥离的提示照旧单独弹;其它写入照旧弹「Saved — with warnings」;表单关闭时把没认领的警告全部照常弹出,不丢。回滚 = revert 本 PR。 - **先看**:`ui/packages/app-shell/src/providers/writeWarningToast.ts` 的 `emitWriteWarning` 与 `claimAnswerWarnings`(风险点:认领判定必须在第一个 `await` 之前同步完成);`console/AppContent.tsx` 的 `answerWarningClaim` effect 与 `handleRecordFormSuccess`;`views/RecordFormPage.tsx` 的 `handleSuccess`。 - **两分钟验证**:在 `ui/` 下跑 `pnpm exec vitest run packages/app-shell/src/views/RecordFormPage.saveToastCarriesWarnings-27999.test.tsx packages/app-shell/src/console/__tests__/AppContent.recordSaveOneToast-27999.test.tsx`,预期 2 个文件、10 个用例全部通过。 - **需要你做的**:无,席位落地。 ## What changes - **The write-warning channel** (`providers/writeWarningToast.ts`). New `claimAnswerWarnings(adapter, scope)`: a record save surface claims the answer warnings of the writes it makes, for as long as it is open. The scope is the record's update (object + id), or a create of the object. `emitWriteWarning` now decides, before its first `await`, whether an open claim on the adapter it is handed matches the event; if so the event's `warnings` are parked on that claim instead of toasted. The stripped-fields toast is never claimed and is unchanged. `take(saved)` returns the distinct messages of the write that answered `saved` (an update's parked events already name its record; a create's are its own when the answer's record id is the saved record's id) and shows every other parked event on the channel's own toast at once. `release()` shows whatever is still parked. No warning is dropped on either path. `toastRecordSave(sink, message, warnings)` is the one save toast: the warning variant with the lines as its description when there are warnings, the success toast otherwise. - **Overlay form** (`console/AppContent.tsx`). While `?form=` is open, an effect holds a claim keyed on the URL primitives (`recordFormParam`, the form object's name), not on the memoised `editingRecord` / `formObjectDef` (ui/AGENTS.md #10). `handleRecordFormSuccess` takes its write's warnings first; the child-form task passes them to `toastRecordSave`, the main path hands them to `crud_success` as `params.warnings`, and the `crud_success` handler toasts through `toastRecordSave`. With no `warnings` param it is the success toast it was. - **Page-mode form** (`views/RecordFormPage.tsx`). Same claim for the page's mode, object and record id; `handleSuccess` takes the warnings and toasts through `toastRecordSave`. The record naming of #26172 / #26461 is unchanged. - **Why this route and not the others.** The adapter emits the write-warning event inside `create` / `update`, before the write's promise settles, and the form calls `onSuccess` only after that promise settles. So a claim opened while the form is open is already holding the warnings when the surface's success handler runs. That order is fixed by the code, not by timing, and it does not depend on how long the stripped-fields label lookup takes. The other routes were weaker. Deferring the channel's toast and letting the surface cancel it is a race against the form's own awaits. A per-write flag on the request, or returning the answer's `warnings` to `onSuccess`, would change `@object-ui/data-objectstack`'s published write API and every `plugin-form` variant: a wider surface and a Clause-② change. The claim stays inside the private `@object-ui/app-shell` and moves no published export or i18n key. - **Changeset** `.changeset/27999-save-with-warnings-one-toast.md`, `@object-ui/app-shell` + `@object-ui/console` patch. ## Tests - `providers/writeWarningToast.test.ts`: 5 new cells through the real adapter chain (the stubbed transport answers, `onWriteWarning` hands the event on): a claimed update leaves the channel silent and `take` returns the message; a claimed create takes only its own record id's warnings and shows another create's on the channel toast; the stripped-fields toast keeps firing while the warnings are claimed; CONTROL: writes the claim does not name keep the channel toast; a claim closed without a take shows what it parked and stops claiming. - `views/RecordFormPage.saveToastCarriesWarnings-27999.test.tsx` (new, 5 cells) and `console/__tests__/AppContent.recordSaveOneToast-27999.test.tsx` (new, 5 cells, from the #26172 harness, with the runner running the real `crud_success` handler). Each replays the real order: the channel's `emitWriteWarning` is called with the adapter, NOT awaited, and then `onSuccess(saved)` runs. The channel's sink and the surface's toast are the same sonner `toast`, so every toast the user would see is counted: one toast, the record-named message carrying the warning lines (update, create, child-form task); the stripped-fields notice still toasts beside it; CONTROL: a write the surface did not make keeps "Saved — with warnings" and the save toast stays a success; a save without warnings is the success toast. - Existing pins kept unchanged and green: `AppContent.recordSaveToastNamesRecord-26172`, `RecordFormPage.saveToastNamesRecord-26461`, the `ConsoleToaster.*` files (including the `crud_success` auto-dismiss pin), `AdapterProvider.*`, and the #12107 cells in `writeWarningToast.test.ts`. <details><summary>Test runs (head 2fcbdbc)</summary> - Narrowed pin set, 61 files: every `RecordFormPage*` and `AppContent*` test under `packages/app-shell` and `apps/console`, `AdapterProvider*`, `ConsoleToaster*`, `writeWarningToast.test.ts`. `pnpm --dir ui exec vitest run --maxWorkers=2 FILES` under `scripts/pm/os-verify-lock.sh`: `VERDICT command-exit 0`, Test Files 61 passed (61), Tests 433 passed (433). - `pnpm --filter @object-ui/app-shell run type-check`: `VERDICT command-exit 0`. It covers the new tests: on an earlier head, `tsc -p tsconfig.test.json` went red with TS7031 on two callbacks in the new overlay test, which were then typed. - The whole `packages/app-shell` suite (1963 files) was not run locally. At two workers it would have held the shared verification lock for over an hour, so the run was stopped and narrowed to the set above. `Test UI` in CI runs the full sharded set. </details> <details><summary>Ablation (head 2fcbdbc)</summary> `node scripts/ablation-replace.mjs` on `providers/writeWarningToast.ts`: the anchor `const claim = messages.length > 0 && adapter ? matchingClaim(adapter, ev) : undefined;` was replaced so that the claim lookup never runs (the channel ignores every claim). The anchor went x1 to x0, the replacement x0 to x1, and the blob went `af23c268` to `95733a37`. Direction, written before the run: 11 RED (4 channel cells, 3 page-mode cells, 4 overlay cells); every CONTROL cell and the earlier #12107 cells GREEN. Observed: Tests 11 failed, 24 passed (35), exactly those 11. Restored blob `af23c268` = HEAD blob, `git diff HEAD` empty. </details> ## Acceptance notes - The batch path (`batchTransaction`, used by master-detail record forms) carries no answer `warnings` into the write-warning event today. A master-detail save therefore has no warnings for either toast to show. This PR neither widens nor narrows that. - While a create form is open, a create of the same object made elsewhere in the same tab is parked on the claim, then shown on its own channel toast when the form saves or closes. Its warning arrives late but is not lost. The modal blocks the page behind it, so no ordinary flow reaches this case. - The save toast with warnings uses sonner's warning variant, so the advisory is visible at a glance. Its title is still the record-named save message. ## Verification <details><summary>Gates (head 2fcbdbc)</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 34 commands; each is recorded as command :: exit code, captured before any pipe: ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm --dir ui run check:new-line-citations :: exit 0 node scripts/check-platform-checklist.mjs --changed-from origin/main :: exit 0 (0 anchored files in the change set) ``` `dispatch-gates --ran`: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. `node scripts/check-sdui-manifest.mjs` exit 0. The CI-owned families (the lint farm, Test UI, Type Check UI) are read from this PR's check runs, not re-run here. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CbDAucNyQ4UtzCJKyh)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 86c6955 commit 121d1a5

7 files changed

Lines changed: 800 additions & 17 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
fix(console): a record save whose answer carries warnings shows one toast, the record-named save message carrying the warning lines
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
A save from the record form (the overlay dialog, its child-form task, or the full-page form) that passed a `severity: 'warning'` validation rule showed two toasts: the write-warning channel's "Saved — with warnings" with the rule messages, then the form's own `OBJECT "TITLE" updated`. It now shows one: the record-named save message, on the warning variant, with each distinct rule message on its own line. While a record form is open, the write-warning channel hands the answer warnings of the write that form makes (that record's update, or a create of that object) to the form instead of toasting them; the stripped-fields notice keeps its own toast, and any other write keeps the "Saved — with warnings" toast.

‎ui/packages/app-shell/src/console/AppContent.tsx‎

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type { AppAccessVerdict, ConnectionState } from '@object-ui/data-objectst
2222
import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth';
2323
import { useMetadata } from '../providers/MetadataProvider.js';
2424
import { useAdapter } from '../providers/AdapterProvider.js';
25+
import { claimAnswerWarnings, toastRecordSave, type AnswerWarningClaim } from '../providers/writeWarningToast.js';
2526
import { usePreviewDrafts } from '../preview/PreviewModeContext.js';
2627
import { PreviewDraftEmptyState } from '../preview/PreviewDraftEmptyState.js';
2728
import {
@@ -764,7 +765,9 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
764765
runner.registerHandler('crud_success', async (action: any) => {
765766
closeRecordForm();
766767
setRefreshKey(k => k + 1);
767-
toast.success(action.params?.message ?? 'Record saved successfully');
768+
// `warnings`: the save's own advisory warning lines, which this one
769+
// toast carries (objectstack-ai/objectstack#27999).
770+
toastRecordSave(toast, action.params?.message ?? 'Record saved successfully', action.params?.warnings);
768771
return { success: true, reload: true };
769772
});
770773

@@ -901,10 +904,30 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
901904
: t('form.createSuccess', { object: label, defaultValue: `${label} created successfully` });
902905
}, [editingRecord, formObjectDef, objectLabel, t]);
903906

904-
const handleCrudSuccess = useCallback((saved?: any) => {
907+
// objectstack-ai/objectstack#27999 — the open record form reports its own
908+
// write's advisory warnings: while the overlay is open, the write-warning
909+
// channel parks the answer warnings of the write it names (this record's
910+
// update, or a create of this object) on the claim instead of toasting them,
911+
// and the save toast carries them. Keyed on the URL's primitives, not on the
912+
// memoised `editingRecord` / `formObjectDef` (ui/AGENTS.md #10).
913+
const answerWarningClaim = useRef<AnswerWarningClaim | null>(null);
914+
const formObjectName: string | undefined = formObjectDef?.name;
915+
useEffect(() => {
916+
if (!dataSource || !recordFormParam || !formObjectName) return;
917+
const claim = claimAnswerWarnings(dataSource, recordFormParam === 'new'
918+
? { operation: 'create', resource: formObjectName }
919+
: { operation: 'update', resource: formObjectName, id: recordFormParam });
920+
answerWarningClaim.current = claim;
921+
return () => {
922+
if (answerWarningClaim.current === claim) answerWarningClaim.current = null;
923+
claim.release();
924+
};
925+
}, [dataSource, recordFormParam, formObjectName]);
926+
927+
const handleCrudSuccess = useCallback((saved?: any, warnings?: string[]) => {
905928
executeAction({
906929
type: 'crud_success',
907-
params: { message: recordSaveMessage(saved) },
930+
params: { message: recordSaveMessage(saved), warnings },
908931
});
909932
}, [executeAction, recordSaveMessage]);
910933

@@ -925,12 +948,13 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
925948
// the child's open related lists refetch on their own via the
926949
// invalidation bus (#2269): the dataSource write already emitted a
927950
// MutationEvent that the bridge fans out — no manual notify needed.
951+
const warnings = answerWarningClaim.current?.take(saved) ?? [];
928952
if (isChildFormTask) {
929-
toast.success(recordSaveMessage(saved));
953+
toastRecordSave(toast, recordSaveMessage(saved), warnings);
930954
closeRecordForm();
931955
return;
932956
}
933-
handleCrudSuccess(saved);
957+
handleCrudSuccess(saved, warnings);
934958
if (editingRecord || !currentObjectDef) return;
935959
const target = resolvePostCreateTarget({
936960
objectName: currentObjectDef.name,
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
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#27999 — an overlay record save whose answer
11+
* carries advisory warnings (a `severity: 'warning'` validation rule) shows ONE
12+
* toast: the record-named save message (objectstack-ai/objectstack#26172),
13+
* carrying the warning lines. The write-warning channel's "Saved — with
14+
* warnings" toast is not raised for that write; its stripped-fields notice
15+
* still is, and a write the overlay did not make keeps the channel toast.
16+
*
17+
* Measured from a real `AppContent` mount (the
18+
* `AppContent.recordSaveToastNamesRecord-26172` harness): the global record-form
19+
* modal is a stand-in, and the save does what the real chain does, in its
20+
* order: the adapter emits the write-warning event INSIDE the write (here the
21+
* channel's `emitWriteWarning`, handed the adapter as `AdapterProvider` hands
22+
* it, and not awaited), then the form hands the write's result to `onSuccess`.
23+
* The runner runs the handler `AppContent` registered, so the main path's
24+
* toast is the real `crud_success` one; the child-form task toasts directly.
25+
* The channel's sink is the same sonner `toast`, so every toast is counted.
26+
*
27+
* Direction, written before the run: with the channel ignoring the overlay's
28+
* claim, every "one toast" cell RED (a second "Saved — with warnings" toast,
29+
* and a success toast carrying no warning line); the CONTROL cells GREEN on
30+
* both sides.
31+
*/
32+
33+
import '@testing-library/jest-dom/vitest';
34+
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest';
35+
import React from 'react';
36+
import { render, cleanup, waitFor } from '@testing-library/react';
37+
import { MemoryRouter, Routes, Route } from 'react-router-dom';
38+
import { toast } from 'sonner';
39+
import { createI18n, I18nProvider } from '@object-ui/i18n';
40+
import type { WriteWarningEvent } from '@object-ui/data-objectstack';
41+
import { emitWriteWarning } from '../../providers/writeWarningToast';
42+
43+
/** The part of the modal's schema this file reads. */
44+
interface ModalSchemaProbe {
45+
objectName?: string;
46+
onSuccess: (saved: Record<string, unknown>) => Promise<void> | void;
47+
}
48+
const { modalSchemas, handlers, execute } = vi.hoisted(() => {
49+
const handlers = new Map<string, (action: unknown) => Promise<unknown>>();
50+
return {
51+
modalSchemas: [] as ModalSchemaProbe[],
52+
handlers,
53+
execute: vi.fn(async (action: { type?: string }) => handlers.get(action?.type ?? '')?.(action)),
54+
};
55+
});
56+
57+
/** The global record-form modal: its schema carries the `onSuccess` under test. */
58+
vi.mock('@object-ui/plugin-form', async (importOriginal) => ({
59+
...(await importOriginal<Record<string, unknown>>()),
60+
ModalForm: ({ schema }: { schema: ModalSchemaProbe }) => {
61+
modalSchemas.push(schema);
62+
return <div data-testid="modal-form" />;
63+
},
64+
}));
65+
66+
vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({
67+
...(await importOriginal<typeof import('@object-ui/plugin-designer')>()),
68+
CreateAppPage: () => <div data-testid="create-app-page" />,
69+
EditAppPage: () => <div data-testid="edit-app-page" />,
70+
DashboardDesignPage: () => <div data-testid="dashboard-design-page" />,
71+
}));
72+
73+
vi.mock('../../layout/ConsoleLayout', () => ({
74+
ConsoleLayout: ({ children }: { children?: React.ReactNode }) => (
75+
<div data-testid="console-layout">{children}</div>
76+
),
77+
}));
78+
vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null }));
79+
vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null }));
80+
vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null }));
81+
vi.mock('../../views/ObjectView', () => ({ ObjectView: () => <div data-testid="object-view" /> }));
82+
vi.mock('../../views/RecordDetailView', () => ({ RecordDetailView: () => <div data-testid="record-page" /> }));
83+
84+
vi.mock('@object-ui/auth', async (importOriginal) => ({
85+
...(await importOriginal<Record<string, unknown>>()),
86+
useAuth: () => ({
87+
user: { id: 'u1', name: 'Ada', email: 'ada@example.com', role: 'user' },
88+
getAuthConfig: async () => ({ features: {} }),
89+
activeOrganization: null,
90+
}),
91+
useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
92+
}));
93+
94+
// The adapter the overlay claims on, and the one the channel is handed as
95+
// `AdapterProvider` hands it: the same object.
96+
const dataSourceStub = {
97+
onConnectionStateChange: () => () => {},
98+
getConnectionState: () => 'connected',
99+
getObjectSchema: async () => ({ fields: {} as Record<string, { label?: string }> }),
100+
};
101+
vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({
102+
...(await importOriginal<Record<string, unknown>>()),
103+
useAdapter: () => dataSourceStub,
104+
}));
105+
106+
const OBJECTS = [
107+
// Declares its name field: a saved task is named by `subject`.
108+
{
109+
name: 'showcase_task',
110+
label: 'Task',
111+
nameField: 'subject',
112+
fields: { subject: { type: 'text', label: 'Subject' }, done: { type: 'boolean', label: 'Done' } },
113+
},
114+
// The parent the child-form task opens over.
115+
{ name: 'showcase_project', label: 'Project', fields: { name: { type: 'text', label: 'Name' } } },
116+
];
117+
118+
const APPS = [{ name: 'crm', label: 'CRM', isDefault: true, navigation: [] }];
119+
120+
// One value for the whole run: the route's effects key on these members.
121+
const METADATA = {
122+
apps: APPS,
123+
objects: OBJECTS,
124+
loading: false,
125+
ensureType: undefined,
126+
error: null,
127+
refresh: async () => {},
128+
};
129+
vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({
130+
...(await importOriginal<Record<string, unknown>>()),
131+
useMetadata: () => METADATA,
132+
}));
133+
134+
// The runner runs what `AppContent` registered, so the `crud_success` toast is
135+
// the real handler's.
136+
const ACTION_RUNNER = {
137+
execute,
138+
runner: {
139+
registerHandler: (type: string, fn: (action: unknown) => Promise<unknown>) => { handlers.set(type, fn); },
140+
getContext: () => ({}),
141+
},
142+
};
143+
vi.mock('@object-ui/react', async (importOriginal) => ({
144+
...(await importOriginal<Record<string, unknown>>()),
145+
useActionRunner: () => ACTION_RUNNER,
146+
useGlobalUndo: () => {},
147+
useMutationInvalidationBridge: () => {},
148+
}));
149+
150+
// objectstack#27138 — an edit dialog reads the approvals state of the record it
151+
// edits (`useRecordApprovals`). No approval runs in this file: the approvals
152+
// routes answer empty from one double, installed for the whole file so no read
153+
// can reach a socket after a test body returns, and handed back in `afterAll`.
154+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ data: [] }), {
155+
status: 200,
156+
headers: { 'Content-Type': 'application/json' },
157+
})));
158+
afterAll(() => vi.unstubAllGlobals());
159+
160+
import { AppContent } from '../AppContent';
161+
162+
/** The channel's translator: the English default, interpolated. */
163+
const t = (_key: string, opts?: Record<string, unknown>) =>
164+
String(opts?.defaultValue ?? '').replace('{{fields}}', String(opts?.fields ?? ''));
165+
const identityLabel = (_o: string, _f: string, fallback: string) => fallback;
166+
167+
const MAIN_CREATE = '/apps/crm/showcase_task?form=new';
168+
const MAIN_EDIT = '/apps/crm/showcase_task?form=t1';
169+
const CHILD_EDIT = '/apps/crm/showcase_project/record/p1?form=t1&formObject=showcase_task';
170+
171+
const ADVISORY = {
172+
field: 'subject',
173+
code: 'validation_rule',
174+
message: 'Reviews are usually scheduled a week ahead',
175+
rule: 'review_lead_time',
176+
severity: 'warning' as const,
177+
};
178+
const SECOND = { ...ADVISORY, field: 'done', rule: 'done_owner', message: 'Done tasks usually name an owner' };
179+
const NAMED = { id: 't1', subject: 'Quarterly review' };
180+
181+
let toastSuccess: ReturnType<typeof vi.spyOn>;
182+
let toastWarning: ReturnType<typeof vi.spyOn>;
183+
beforeEach(() => {
184+
modalSchemas.length = 0;
185+
execute.mockClear();
186+
toastSuccess = vi.spyOn(toast, 'success');
187+
toastWarning = vi.spyOn(toast, 'warning');
188+
});
189+
afterEach(() => {
190+
toastSuccess.mockRestore();
191+
toastWarning.mockRestore();
192+
cleanup();
193+
});
194+
195+
/** Mount at `url`, save `saved` with `event` emitted by the write, and settle every toast. */
196+
async function saveFrom(url: string, event: WriteWarningEvent, saved: Record<string, unknown>): Promise<void> {
197+
const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
198+
render(
199+
<I18nProvider instance={i18n}>
200+
<MemoryRouter initialEntries={[url]}>
201+
<Routes>
202+
<Route path="/apps/:appName/*" element={<AppContent />} />
203+
</Routes>
204+
</MemoryRouter>
205+
</I18nProvider>,
206+
);
207+
await waitFor(() => expect(modalSchemas.length).toBeGreaterThan(0));
208+
// The overlay's claim is opened by an effect of the mounted, open dialog.
209+
await waitFor(() => expect(handlers.has('crud_success')).toBe(true));
210+
const schema = modalSchemas[modalSchemas.length - 1];
211+
expect(schema.objectName).toBe('showcase_task');
212+
const channel = emitWriteWarning(event, t, dataSourceStub as never, identityLabel, toast);
213+
await schema.onSuccess(saved);
214+
await channel;
215+
await waitFor(() => expect(toastSuccess.mock.calls.length + toastWarning.mock.calls.length).toBeGreaterThan(0));
216+
}
217+
218+
const update = (over: Partial<WriteWarningEvent> = {}): WriteWarningEvent => ({
219+
operation: 'update',
220+
resource: 'showcase_task',
221+
id: 't1',
222+
droppedFields: [],
223+
warnings: [ADVISORY],
224+
...over,
225+
});
226+
227+
describe('AppContent — an overlay save with warnings shows one record-named toast (objectstack-ai/objectstack#27999)', () => {
228+
it('update (crud_success): one toast, the record-named message carrying the warning line', async () => {
229+
await saveFrom(MAIN_EDIT, update(), NAMED);
230+
231+
expect(toastSuccess).not.toHaveBeenCalled();
232+
expect(toastWarning.mock.calls).toEqual([
233+
['Task "Quarterly review" updated', { description: ADVISORY.message }],
234+
]);
235+
});
236+
237+
it('create (crud_success): one toast, each distinct warning on its own line', async () => {
238+
await saveFrom(
239+
MAIN_CREATE,
240+
{ operation: 'create', resource: 'showcase_task', id: 't1', droppedFields: [], warnings: [ADVISORY, SECOND] },
241+
NAMED,
242+
);
243+
244+
expect(toastSuccess).not.toHaveBeenCalled();
245+
expect(toastWarning.mock.calls).toEqual([
246+
['Task "Quarterly review" created', { description: `${ADVISORY.message}\n${SECOND.message}` }],
247+
]);
248+
});
249+
250+
it('child-form task: the direct toast is the one toast', async () => {
251+
await saveFrom(CHILD_EDIT, update(), NAMED);
252+
253+
expect(toastSuccess).not.toHaveBeenCalled();
254+
expect(toastWarning.mock.calls).toEqual([
255+
['Task "Quarterly review" updated', { description: ADVISORY.message }],
256+
]);
257+
});
258+
259+
it('the stripped-fields notice keeps its own toast beside the save toast', async () => {
260+
await saveFrom(
261+
MAIN_EDIT,
262+
update({ droppedFields: [{ object: 'showcase_task', fields: ['done'], reason: 'readonly' }] }),
263+
NAMED,
264+
);
265+
266+
expect(toastSuccess).not.toHaveBeenCalled();
267+
expect(toastWarning.mock.calls.map((call: unknown[]) => call[0]).sort()).toEqual([
268+
'Saved — but some fields did not take effect',
269+
'Task "Quarterly review" updated',
270+
]);
271+
expect(toastWarning.mock.calls.find((call: unknown[]) => call[0] === 'Task "Quarterly review" updated')?.[1]).toEqual({
272+
description: ADVISORY.message,
273+
});
274+
});
275+
276+
it('CONTROL: a write the overlay did not make keeps the channel toast, and the save toast stays a success', async () => {
277+
await saveFrom(MAIN_EDIT, update({ id: 't9' }), NAMED);
278+
279+
expect(toastSuccess.mock.calls).toEqual([['Task "Quarterly review" updated']]);
280+
expect(toastWarning.mock.calls).toEqual([['Saved — with warnings', { description: ADVISORY.message }]]);
281+
});
282+
});

0 commit comments

Comments
 (0)