Skip to content

Commit b1e1dcf

Browse files
feat(console): a save's toast shows the notice a hook set on the write, one toast per write (#28307)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao7psmQXENcMMMPyS21PpPWj)_ Fixes #28252 Refs #28000 Clause-②: yes (widening: `@object-ui/data-objectstack`'s `WriteWarningEvent` gains an optional `notice`; `@object-ui/react` gains the type `RecordSaveFeedback`, `AnswerWarningsEvent` gains an optional `notice`, and `AnswerWarningClaim.take` / `toastRecordSave` move to it. Those two were added by #28267 and have not been released, so no released signature changes) Metadata-protocol: none ## 维护者速读 - 改了什么:保存记录时,如果服务端钩子给这次保存留了一句话(`notice`,比如「已提交给经理审批」),控制台现在会显示它。记录弹窗(含子表单任务)、整页表单、`object-form` 抽屉/弹窗、表格行内编辑(单行保存和「全部保存」)都在原来的保存提示里把这句话放在标题下面;仍然只弹一个提示。 - 为什么改:#28000 的契约半边(#28247)已经让创建/更新的应答带上 `notice`,但控制台没有任何地方读它,HotCRM「保存即送审」的提示被丢掉了(#28252)。适配器原本只把应答里的记录交出去,`notice` 在那一层就被丢了。 - 风险与回滚:适配器 `create`/`update` 返回值不变,只是写入提醒事件多带一个可选字段;只有提醒的保存仍是成功样式,同时有警告时才是警告样式。没有页面认领的写入(例如记录详情页行内编辑的保存条)由写入提醒通道弹一个 info 提示。回滚即 revert 本 PR。 - 先看:`ui/packages/react/src/record-save-feedback.ts`(`take` 交回 `RecordSaveFeedback`,`toastRecordSave` 的样式选择);`ui/packages/plugin-grid/src/ObjectGrid.tsx`(行内保存新增认领,「全部保存」按行认领再合并成一个提示);`ui/packages/app-shell/src/providers/writeWarningToast.ts`(未被认领时通知的落点)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/react/src/record-save-feedback.test.ts packages/plugin-grid/src/__tests__/inlineEditSaveNotice-28252.test.tsx`,预期 2 个文件全绿。 - 需要你做的:无,席位落地。 ## What changes **Which surfaces show the notice** (the card's should-fix (b)), one toast per write in every row: | Surface | Toast | |:---|:---| | Record dialog (`AppContent` `crud_success`) and its child-form task | its record-named message, the notice under it | | Full-page form (`RecordFormPage`) | its record-named message, the notice under it | | `object-form` drawer and modal (`ObjectFormContainer`) | its record-named message or declared `successMessage`, the notice under it | | Grid inline edit, per-row save and "Save All" (`ObjectGrid`) | "1 row saved" / "N rows saved", the notices under it (distinct, one per line) | | Any write no surface claims (the record page's inline-edit Save bar, which raises no save toast of its own; kanban, actions, …) | the write-warning channel's own toast: an info toast with the notice as written, or the notice leading the "Saved — with warnings" lines | A notice alone keeps the save toast on the success variant (it reports an outcome, never a problem); with warnings too, the toast is the warning variant and the notice leads its lines. The stripped-fields toast stays its own toast, as before. **The adapter** (`ui/packages/data-objectstack/src/index.ts`). Measured: `create` / `update` resolved `result.record` and read only `droppedFields` and `warnings` off the answer, so `notice` was dropped there. `notifyDroppedFields` now also reads `notice` (a non-empty string, else nothing) onto the write-warning event, and emits the event when the notice is the answer's only news. What the two methods resolve is unchanged. The batch path is not touched: the spec declares `notice` on the single-record answers only. **The claim** (`ui/packages/react/src/record-save-feedback.ts`). `AnswerWarningsEvent` gains `notice`; `take(saved)` returns `RecordSaveFeedback` (`warnings`, optional `notice`, the last one parked winning as on the server); `toastRecordSave(sink, message, feedback)` titles the toast with the surface's message and puts the notice, then the warnings, under it. **The channel** (`ui/packages/app-shell/src/providers/writeWarningToast.ts`). `emitWriteWarning` parks when the event carries warnings or a notice, still synchronously before its first `await`. Unclaimed, the notice is one toast as the table says. `WriteWarningSink` gains `info`; sonner's `toast`, which `AdapterProvider` passes, has it. The type is not exported from the package. **The surfaces.** `AppContent`, `RecordFormPage` and `ObjectFormContainer` hand `take`'s result to `toastRecordSave` (the `crud_success` params carry `notice` beside `warnings`). `ObjectGrid`'s default row save opens a claim on the row's update for the length of the save, and "Save All" one per row, read once into the single toast, released in a `finally`. ## Tests New: - `ui/packages/plugin-grid/src/__tests__/inlineEditSaveNotice-28252.test.tsx` (3): per-row save shows one success toast with the notice, the channel nothing; "Save All" over two rows shows one warning toast, the shared notice once then the warning line; CONTROL: a write the grid did not make keeps the channel toast after the save. - Added cells to `onWriteWarning.test.ts` (3: create and update carry the notice and still resolve the record; empty or non-string notice emits nothing), `record-save-feedback.test.ts` (3), `writeWarningToast.test.ts` (5: claimed update and create take it; unclaimed notice alone is one info toast; beside warnings it leads the one warnings toast; a released claim shows it), `RecordFormPage.saveToastCarriesWarnings-27999.test.tsx` (3), `AppContent.recordSaveOneToast-27999.test.tsx` (4) and `containerSaveToastCarriesWarnings-28216.test.tsx` (4, drawer and modal). - Updated: the existing `take` and `toastRecordSave` assertions read the new object shape; their meaning is unchanged. **Ablation** (through `scripts/ablation-replace.mjs`, each mutation proved on disk and restored to the HEAD blob): - channel parks on warnings only → 5 notice cells red across `writeWarningToast`, `RecordFormPage` and `AppContent` (the notice-with-warning cells stay green, as predicted: the warnings still open the park); - adapter drops `notice` from the event → 7 red across `onWriteWarning` and `writeWarningToast`; - grid per-row save toasts without `take` → 2 red in the new grid file (the per-row cell, and its CONTROL, whose count includes the notice the unread claim hands back). ## Acceptance notes - The record page's inline-edit Save bar (`InlineEditSaveBar`) raises no save toast at all today, so it is not a claiming surface; its notice reaches the user through the channel's info toast, which is one toast per write. Giving that bar a save toast of its own is outside this card. - "Save All" joins distinct notices from several rows one per line under one toast; rows with the same notice show it once. - Not measured live in a browser against a hook that sets `onNotice`; the server half that lets sandboxed and flow hooks set it is #28251. The chain is pinned through the real adapter (`writeWarningToast.test.ts`), the real `RecordFormPage` / `AppContent` / `ObjectFormContainer` / `ObjectGrid` save paths in jsdom. ## Verification <details><summary>Local runs</summary> Head `4056fd97`, on `main` `6285203d`. `main` has moved 7 commits since; none touches a file in this diff and `git merge-tree` with `origin/main` (`53749673`) is clean. - `pnpm turbo run build --filter='@object-ui/app-shell^...' --filter='@object-ui/plugin-grid^...' --concurrency=2` (the dependency closure, under the verify lock) → `Tasks: 64 successful, 64 total`, VERDICT command-exit 0; then the four touched lower packages rebuilt → VERDICT command-exit 0. - `type-check` of `@object-ui/data-objectstack`, `@object-ui/react`, `@object-ui/plugin-form`, `@object-ui/plugin-grid`, `@object-ui/app-shell` (each package's `tsc --noEmit`, plus `tsc -p tsconfig.test.json` where it declares one) → exit 0 each. - The eight toast files (new grid file, `inlineEditSaveFeedback-12303`, `onWriteWarning`, `record-save-feedback`, `writeWarningToast`, `RecordFormPage.saveToastCarriesWarnings-27999`, `AppContent.recordSaveOneToast-27999`, `containerSaveToastCarriesWarnings-28216`) → `Test Files 8 passed (8)`, `Tests 100 passed (100)`. - Under the verify lock: `pnpm --dir ui exec vitest run packages/react/ packages/data-objectstack/ packages/plugin-form/ packages/plugin-grid/` plus the 130 app-shell and console test files that name `writeWarningToast`, `RecordFormPage`, `AppContent`, `toastRecordSave`, `claimAnswerWarnings`, `ObjectFormContainer` or `onWriteWarning` → `Test Files 845 passed (845)`, `Tests 8464 passed | 1 skipped (8465)`, VERDICT command-exit 0. The whole `packages/app-shell/` suite was not run locally (CI's Test UI runs it). - eslint on the 14 changed `ts` / `tsx` files → 0 errors. `pnpm --dir ui run check:new-line-citations` → 0 new citations, exit 0. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `4056fd97` → 35 commands, each run with stdin from /dev/null and its exit code captured before any pipe; `--ran` reconcile: `35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN`, exit 0. `check:type-check-debt`'s first run was a lock queue-timeout (exit 3, NOT MEASURED, behind this branch's own test run); the line below is its re-run under the lock. ``` 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 --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:error-status-conformance :: 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 exec vitest run scripts/ :: exit 0 ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibwGdCkvhAHHVnAyVG1)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent be25e2d commit b1e1dcf

15 files changed

Lines changed: 701 additions & 62 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/data-objectstack': patch
3+
'@object-ui/react': patch
4+
'@object-ui/app-shell': patch
5+
'@object-ui/plugin-form': patch
6+
'@object-ui/plugin-grid': patch
7+
---
8+
9+
feat(console): a save's toast shows the notice a hook set on the write, still one toast per write
10+
11+
Clause-②: yes (widening)
12+
Metadata-protocol: none
13+
14+
A create or update answer can carry a `notice`: one message a hook on the write set for the person who made it, such as "Sent to your manager for approval". The Console now shows it. The record form's save toast (the record dialog, its child-form task and the full-page form), the `object-form` drawer and modal, and the grid's inline-edit saves (the per-row save and "Save All") keep their own message as the title and show the notice under it. The toast stays a success when the notice is the only news, and moves to the warning variant, with the notice leading the lines, when the answer also carried warnings. A write no save surface reports itself, such as the record page's inline edit Save bar, gets one toast from the write-warning channel instead: an info toast with the notice as the hook wrote it, or the notice leading the "Saved — with warnings" lines.
15+
16+
- **`@object-ui/data-objectstack`**: `WriteWarningEvent` gains an optional `notice`, read off the single-record create and update answers. The event is now also emitted when the notice is the answer's only news. What `create` and `update` resolve is unchanged.
17+
- **`@object-ui/react`**: new type `RecordSaveFeedback` (`warnings`, optional `notice`). `AnswerWarningClaim.take` returns it, `AnswerWarningsEvent` gains `notice`, and `toastRecordSave` takes it as its third argument. These exports have not been released yet, so no released signature changes.

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

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { useAssistant } from '../assistant/assistantBus.js';
1414
import { ModalForm } from '@object-ui/plugin-form';
1515
import { Empty, EmptyTitle, EmptyDescription, Button, DataErrorState } from '@object-ui/components';
1616
import { toast } from 'sonner';
17-
import { useActionRunner, useGlobalUndo, useMutationInvalidationBridge, notifyDataChanged, FilterScopeProvider, resolveApprovalLock, claimAnswerWarnings, toastRecordSave, type AnswerWarningClaim } from '@object-ui/react';
17+
import { useActionRunner, useGlobalUndo, useMutationInvalidationBridge, notifyDataChanged, FilterScopeProvider, resolveApprovalLock, claimAnswerWarnings, toastRecordSave, type AnswerWarningClaim, type RecordSaveFeedback } from '@object-ui/react';
1818
import { useRecordApprovals, openApprovalOf } from '../hooks/useRecordApprovals.js';
1919
import { useObjectTranslation, useObjectLabel, recordSaveMessage as buildRecordSaveMessage } from '@object-ui/i18n';
2020
import type { AppAccessVerdict, ConnectionState } from '@object-ui/data-objectstack';
@@ -763,9 +763,13 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
763763
runner.registerHandler('crud_success', async (action: any) => {
764764
closeRecordForm();
765765
setRefreshKey(k => k + 1);
766-
// `warnings`: the save's own advisory warning lines, which this one
767-
// toast carries (objectstack-ai/objectstack#27999).
768-
toastRecordSave(toast, action.params?.message ?? 'Record saved successfully', action.params?.warnings);
766+
// `warnings` / `notice`: the save's own advisory warning lines and its
767+
// hook's notice, which this one toast carries
768+
// (objectstack-ai/objectstack#27999, objectstack-ai/objectstack#28252).
769+
toastRecordSave(toast, action.params?.message ?? 'Record saved successfully', {
770+
warnings: action.params?.warnings,
771+
notice: action.params?.notice,
772+
});
769773
return { success: true, reload: true };
770774
});
771775

@@ -909,10 +913,10 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
909913
};
910914
}, [dataSource, recordFormParam, formObjectName]);
911915

912-
const handleCrudSuccess = useCallback((saved?: any, warnings?: string[]) => {
916+
const handleCrudSuccess = useCallback((saved?: any, feedback?: RecordSaveFeedback) => {
913917
executeAction({
914918
type: 'crud_success',
915-
params: { message: recordSaveMessage(saved), warnings },
919+
params: { message: recordSaveMessage(saved), warnings: feedback?.warnings, notice: feedback?.notice },
916920
});
917921
}, [executeAction, recordSaveMessage]);
918922

@@ -933,13 +937,13 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
933937
// the child's open related lists refetch on their own via the
934938
// invalidation bus (#2269): the dataSource write already emitted a
935939
// MutationEvent that the bridge fans out — no manual notify needed.
936-
const warnings = answerWarningClaim.current?.take(saved) ?? [];
940+
const feedback = answerWarningClaim.current?.take(saved);
937941
if (isChildFormTask) {
938-
toastRecordSave(toast, recordSaveMessage(saved), warnings);
942+
toastRecordSave(toast, recordSaveMessage(saved), feedback);
939943
closeRecordForm();
940944
return;
941945
}
942-
handleCrudSuccess(saved, warnings);
946+
handleCrudSuccess(saved, feedback);
943947
if (editingRecord || !currentObjectDef) return;
944948
const target = resolvePostCreateTarget({
945949
objectName: currentObjectDef.name,

‎ui/packages/app-shell/src/console/__tests__/AppContent.recordSaveOneToast-27999.test.tsx‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,15 +180,18 @@ const NAMED = { id: 't1', subject: 'Quarterly review' };
180180

181181
let toastSuccess: ReturnType<typeof vi.spyOn>;
182182
let toastWarning: ReturnType<typeof vi.spyOn>;
183+
let toastInfo: ReturnType<typeof vi.spyOn>;
183184
beforeEach(() => {
184185
modalSchemas.length = 0;
185186
execute.mockClear();
186187
toastSuccess = vi.spyOn(toast, 'success');
187188
toastWarning = vi.spyOn(toast, 'warning');
189+
toastInfo = vi.spyOn(toast, 'info');
188190
});
189191
afterEach(() => {
190192
toastSuccess.mockRestore();
191193
toastWarning.mockRestore();
194+
toastInfo.mockRestore();
192195
cleanup();
193196
});
194197

@@ -280,3 +283,46 @@ describe('AppContent — an overlay save with warnings shows one record-named to
280283
expect(toastWarning.mock.calls).toEqual([['Saved — with warnings', { description: ADVISORY.message }]]);
281284
});
282285
});
286+
287+
// objectstack-ai/objectstack#28252 — the answer's `notice` (the message a hook
288+
// set, objectstack-ai/objectstack#28000) rides the overlay's claim: the
289+
// `crud_success` toast and the child-form task's direct toast carry it.
290+
describe('AppContent — the overlay save toast carries the hook notice (objectstack-ai/objectstack#28252)', () => {
291+
const NOTICE = 'Sent to your manager for approval';
292+
293+
it('update (crud_success): one success toast, the notice under the record-named message', async () => {
294+
await saveFrom(MAIN_EDIT, update({ warnings: undefined, notice: NOTICE }), NAMED);
295+
296+
expect(toastSuccess.mock.calls).toEqual([['Task "Quarterly review" updated', { description: NOTICE }]]);
297+
expect(toastWarning).not.toHaveBeenCalled();
298+
expect(toastInfo).not.toHaveBeenCalled();
299+
});
300+
301+
it('create (crud_success) with a warning too: one warning toast, the notice leading the lines', async () => {
302+
await saveFrom(
303+
MAIN_CREATE,
304+
{ operation: 'create', resource: 'showcase_task', id: 't1', droppedFields: [], warnings: [ADVISORY], notice: NOTICE },
305+
NAMED,
306+
);
307+
308+
expect(toastSuccess).not.toHaveBeenCalled();
309+
expect(toastInfo).not.toHaveBeenCalled();
310+
expect(toastWarning.mock.calls).toEqual([
311+
['Task "Quarterly review" created', { description: `${NOTICE}\n${ADVISORY.message}` }],
312+
]);
313+
});
314+
315+
it('child-form task: the direct toast carries the notice', async () => {
316+
await saveFrom(CHILD_EDIT, update({ warnings: undefined, notice: NOTICE }), NAMED);
317+
318+
expect(toastSuccess.mock.calls).toEqual([['Task "Quarterly review" updated', { description: NOTICE }]]);
319+
expect(toastInfo).not.toHaveBeenCalled();
320+
});
321+
322+
it('CONTROL: a notice on a write the overlay did not make keeps the channel info toast', async () => {
323+
await saveFrom(MAIN_EDIT, update({ id: 't9', warnings: undefined, notice: NOTICE }), NAMED);
324+
325+
expect(toastSuccess.mock.calls).toEqual([['Task "Quarterly review" updated']]);
326+
expect(toastInfo.mock.calls).toEqual([[NOTICE]]);
327+
});
328+
});

‎ui/packages/app-shell/src/providers/writeWarningToast.test.ts‎

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,10 @@ const EVENT: WriteWarningEvent = {
7979

8080
/** Records what the shell would have shown. */
8181
function makeSink() {
82-
const calls: Array<{ title: string; description?: string }> = [];
82+
const calls: Array<{ title: string; description?: string; variant?: 'info' }> = [];
8383
const sink: WriteWarningSink = {
8484
warning: (title, options) => { calls.push({ title, description: options?.description }); },
85+
info: (title) => { calls.push({ title, variant: 'info' }); },
8586
};
8687
return { sink, calls };
8788
}
@@ -510,7 +511,7 @@ describe('claimAnswerWarnings — the save surface reports its write (objectstac
510511

511512
const saved = await ds.update('quote', 'r1', { discount: 40 });
512513
// Taken as the surface's success handler would, before the channel settles.
513-
expect(claim.take(saved)).toEqual([ADVISORY.message]);
514+
expect(claim.take(saved)).toEqual({ warnings: [ADVISORY.message] });
514515
await settle();
515516
claim.release();
516517

@@ -530,7 +531,7 @@ describe('claimAnswerWarnings — the save surface reports its write (objectstac
530531
await settle();
531532
expect(calls).toEqual([]);
532533

533-
expect(claim.take(saved)).toEqual([ADVISORY.message]);
534+
expect(claim.take(saved)).toEqual({ warnings: [ADVISORY.message] });
534535
expect(calls).toEqual([{ title: WARNED, description: other.message }]);
535536
claim.release();
536537
});
@@ -547,7 +548,7 @@ describe('claimAnswerWarnings — the save surface reports its write (objectstac
547548
const claim = claimAnswerWarnings(adapter, { operation: 'update', resource: 'andon', id: 'r1' });
548549

549550
const saved = await ds.update('andon', 'r1', { type: 'x', source_method: 'y' });
550-
expect(claim.take(saved)).toEqual([ADVISORY.message]);
551+
expect(claim.take(saved)).toEqual({ warnings: [ADVISORY.message] });
551552
await settle();
552553
claim.release();
553554

@@ -589,3 +590,78 @@ describe('claimAnswerWarnings — the save surface reports its write (objectstac
589590
expect(calls).toHaveLength(2);
590591
});
591592
});
593+
594+
// ── a write answer's notice rides the same claim (objectstack-ai/objectstack#28252) ──
595+
//
596+
// Same real chain. The notice a hook set (objectstack-ai/objectstack#28000)
597+
// is one toast per write: the surface's when it claims the write, the
598+
// channel's otherwise.
599+
600+
describe('the answer notice — one toast per write (objectstack-ai/objectstack#28252)', () => {
601+
const NOTICE = 'Sent to your manager for approval';
602+
const WARNED = 'Saved — with warnings';
603+
604+
beforeEach(() => {
605+
vi.clearAllMocks();
606+
adapter.getObjectSchema.mockResolvedValue(ANDON_SCHEMA);
607+
});
608+
609+
it('a claimed update: the channel shows nothing, the surface takes the notice with the warnings', async () => {
610+
const update = vi.fn().mockResolvedValue({ record: { id: 'r1' }, warnings: [ADVISORY], notice: NOTICE });
611+
const { ds, calls, settle } = wiredAdapter({ update });
612+
const claim = claimAnswerWarnings(adapter, { operation: 'update', resource: 'deal', id: 'r1' });
613+
614+
const saved = await ds.update('deal', 'r1', { stage: 'won' });
615+
expect(claim.take(saved)).toEqual({ warnings: [ADVISORY.message], notice: NOTICE });
616+
await settle();
617+
claim.release();
618+
619+
expect(calls).toEqual([]);
620+
});
621+
622+
it('a claimed create whose answer carries only a notice: the surface takes it', async () => {
623+
const create = vi.fn().mockResolvedValue({ record: { id: 'd1' }, notice: NOTICE });
624+
const { ds, calls, settle } = wiredAdapter({ create });
625+
const claim = claimAnswerWarnings(adapter, { operation: 'create', resource: 'deal' });
626+
627+
const saved = await ds.create('deal', { stage: 'won' });
628+
expect(claim.take(saved)).toEqual({ warnings: [], notice: NOTICE });
629+
await settle();
630+
claim.release();
631+
632+
expect(calls).toEqual([]);
633+
});
634+
635+
it('unclaimed, a notice alone is one info toast with the server text as written', async () => {
636+
const update = vi.fn().mockResolvedValue({ record: { id: 'r1' }, notice: NOTICE });
637+
const { ds, calls, settle } = wiredAdapter({ update });
638+
639+
await ds.update('deal', 'r1', { stage: 'won' });
640+
await settle();
641+
642+
expect(calls).toEqual([{ title: NOTICE, variant: 'info' }]);
643+
});
644+
645+
it('unclaimed, a notice beside warnings leads the one warnings toast', async () => {
646+
const update = vi.fn().mockResolvedValue({ record: { id: 'r1' }, warnings: [ADVISORY], notice: NOTICE });
647+
const { ds, calls, settle } = wiredAdapter({ update });
648+
649+
await ds.update('deal', 'r1', { stage: 'won' });
650+
await settle();
651+
652+
expect(calls).toEqual([{ title: WARNED, description: `${NOTICE}\n${ADVISORY.message}` }]);
653+
});
654+
655+
it('a claim closed without a take shows the notice it parked on the channel toast', async () => {
656+
const update = vi.fn().mockResolvedValue({ record: { id: 'r1' }, notice: NOTICE });
657+
const { ds, calls, settle } = wiredAdapter({ update });
658+
const claim = claimAnswerWarnings(adapter, { operation: 'update', resource: 'deal', id: 'r1' });
659+
660+
await ds.update('deal', 'r1', { stage: 'won' });
661+
await settle();
662+
expect(calls).toEqual([]);
663+
claim.release();
664+
665+
expect(calls).toEqual([{ title: NOTICE, variant: 'info' }]);
666+
});
667+
});

‎ui/packages/app-shell/src/providers/writeWarningToast.ts‎

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export type FieldLabelFn = (objectName: string, fieldName: string, fallback: str
5656
*/
5757
export interface WriteWarningSink {
5858
warning(title: string, options?: { description?: string }): void;
59+
info(title: string): void;
5960
}
6061

6162
/**
@@ -251,6 +252,14 @@ function lineFor(reason: DroppedFieldsNotice['reason']): StrippedLine {
251252
* so the claim holds the warnings by the time the surface's success handler
252253
* runs, whatever the label lookup below costs. The stripped-fields toast is
253254
* never claimed.
255+
*
256+
* The answer's `notice` (objectstack-ai/objectstack#28252) — the message a
257+
* hook on the write set for the person who made it — is parked with the
258+
* warnings on the same claim, so a record save surface shows it on its one
259+
* save toast. Unclaimed, it gets the channel's own toast: it leads the
260+
* warnings toast's lines when the write also carried warnings, and is an info
261+
* toast of its own, the server's text as written, when it did not. Either way
262+
* the write's answer feedback is one toast.
254263
*/
255264
export async function emitWriteWarning(
256265
ev: WriteWarningEvent,
@@ -261,23 +270,33 @@ export async function emitWriteWarning(
261270
): Promise<void> {
262271
// Before the first `await`: see the claim paragraph above.
263272
const messages = answerWarningLines(ev);
264-
const claimed = messages.length > 0 && !!adapter
265-
&& parkAnswerWarnings(adapter, ev, () => emitAnswerWarnings(messages, t, sink));
273+
const notice = ev.notice || undefined;
274+
const claimed = (messages.length > 0 || notice !== undefined) && !!adapter
275+
&& parkAnswerWarnings(adapter, ev, () => emitAnswerFeedback(messages, notice, t, sink));
266276
await emitStrippedFields(ev, t, adapter, fieldLabel, sink);
267-
if (!claimed) emitAnswerWarnings(messages, t, sink);
277+
if (!claimed) emitAnswerFeedback(messages, notice, t, sink);
268278
}
269279

270280
/**
271-
* The write answer's advisory `warnings`, one line per distinct message, under
272-
* one title. Nothing when the event carries none.
281+
* The write answer's advisory `warnings`, one line per distinct message under
282+
* one title, led by its `notice` when there is one; a notice alone is an info
283+
* toast of its own. Nothing when the event carries neither.
273284
*/
274-
function emitAnswerWarnings(messages: string[], t: TranslateFn, sink: WriteWarningSink): void {
275-
if (messages.length === 0) return;
285+
function emitAnswerFeedback(
286+
messages: string[],
287+
notice: string | undefined,
288+
t: TranslateFn,
289+
sink: WriteWarningSink,
290+
): void {
291+
if (messages.length === 0) {
292+
if (notice !== undefined) sink.info(notice);
293+
return;
294+
}
276295
sink.warning(
277296
t('detail.writeWarningsTitle', {
278297
defaultValue: 'Saved — with warnings',
279298
}),
280-
{ description: messages.join('\n') },
299+
{ description: (notice !== undefined ? [notice, ...messages] : messages).join('\n') },
281300
);
282301
}
283302

‎ui/packages/app-shell/src/views/RecordFormPage.saveToastCarriesWarnings-27999.test.tsx‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,10 @@ import { RecordFormPage } from './RecordFormPage';
3939

4040
const h = React.createElement;
4141

42-
const { toastSuccess, toastWarning, saveState, ADAPTER } = vi.hoisted(() => ({
42+
const { toastSuccess, toastWarning, toastInfo, saveState, ADAPTER } = vi.hoisted(() => ({
4343
toastSuccess: vi.fn(),
4444
toastWarning: vi.fn(),
45+
toastInfo: vi.fn(),
4546
saveState: { event: undefined as unknown, saved: undefined as Record<string, unknown> | undefined },
4647
ADAPTER: { getObjectSchema: async () => ({ fields: {} as Record<string, { label?: string }> }) },
4748
}));
@@ -50,7 +51,7 @@ vi.mock('sonner', () => ({
5051
toast: Object.assign(vi.fn(), {
5152
success: toastSuccess,
5253
error: vi.fn(),
53-
info: vi.fn(),
54+
info: toastInfo,
5455
warning: toastWarning,
5556
loading: vi.fn(),
5657
dismiss: vi.fn(),
@@ -125,6 +126,7 @@ const NAMED = { id: 't1', subject: 'Quarterly review' };
125126
beforeEach(() => {
126127
toastSuccess.mockClear();
127128
toastWarning.mockClear();
129+
toastInfo.mockClear();
128130
pending.length = 0;
129131
saveState.event = undefined;
130132
saveState.saved = undefined;
@@ -217,3 +219,40 @@ describe('RecordFormPage — a save with warnings shows one record-named toast (
217219
expect(toastWarning).not.toHaveBeenCalled();
218220
});
219221
});
222+
223+
// objectstack-ai/objectstack#28252 — the answer's `notice` (the message a hook
224+
// set, objectstack-ai/objectstack#28000) rides the same claim: still one toast.
225+
// Direction, written before the run: with the channel parking warnings only,
226+
// the notice cells RED (an info toast beside a bare success toast).
227+
describe('RecordFormPage — the save toast carries the hook notice (objectstack-ai/objectstack#28252)', () => {
228+
const NOTICE = 'Sent to your manager for approval';
229+
230+
it('update with a notice only: one success toast, record-named, the notice under it', async () => {
231+
await saveOn('edit', update({ warnings: undefined, notice: NOTICE }), NAMED);
232+
233+
expect(toastSuccess.mock.calls).toEqual([['Task "Quarterly review" updated', { description: NOTICE }]]);
234+
expect(toastWarning).not.toHaveBeenCalled();
235+
expect(toastInfo).not.toHaveBeenCalled();
236+
});
237+
238+
it('create with a notice and a warning: one warning toast, the notice leading the lines', async () => {
239+
await saveOn(
240+
'create',
241+
{ operation: 'create', resource: 'showcase_task', id: 't1', droppedFields: [], warnings: [ADVISORY], notice: NOTICE },
242+
NAMED,
243+
);
244+
245+
expect(toastSuccess).not.toHaveBeenCalled();
246+
expect(toastInfo).not.toHaveBeenCalled();
247+
expect(toastWarning.mock.calls).toEqual([
248+
['Task "Quarterly review" created', { description: `${NOTICE}\n${ADVISORY.message}` }],
249+
]);
250+
});
251+
252+
it('CONTROL: a notice on a write the page did not make keeps the channel info toast', async () => {
253+
await saveOn('edit', update({ resource: 'showcase_project', id: 'p1', warnings: undefined, notice: NOTICE }), NAMED);
254+
255+
expect(toastSuccess.mock.calls).toEqual([['Task "Quarterly review" updated']]);
256+
expect(toastInfo.mock.calls).toEqual([[NOTICE]]);
257+
});
258+
});

0 commit comments

Comments
 (0)