Skip to content

Commit ba5c754

Browse files
fix(plugin-form): a drawer or modal object-form save that answers warnings shows one toast, the record-named message carrying them (#28267)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoLk3JcUupC4oLjMFnZ2TS1Y)_ Fixes #28216 Refs #27453 Clause-②: yes (widening: `@object-ui/react` gains the exports `claimAnswerWarnings`, `parkAnswerWarnings`, `answerWarningLines`, `toastRecordSave` and the types `AnswerWarningsEvent`, `AnswerWarningScope`, `AnswerWarningClaim`, `RecordSaveSink`; they were internal to `@object-ui/app-shell`, which exported none of them, so nothing is removed) Metadata-protocol: none ## 维护者速读 - 改了什么:页面上 `object-form` 抽屉或弹窗保存记录时,如果服务端带回提醒(例如一条警告级的校验规则),现在只弹一个提示:写着记录名的「任务 "Fix login" 已更新」,提醒逐行附在里面;不再另外弹一条「已保存,有提醒」。和记录页、控制台记录弹窗的表现一致。 - 为什么改:#28204 给抽屉/弹窗补上了保存提示,但提醒仍走写入提醒通道自己的提示,一次保存叠出两条,用户关掉成功提示后容易漏看那条提醒属于刚保存的记录(#28216,#28204 合同复审 S1)。 - 风险与回滚:认领逻辑从 app-shell 原样搬到 `@object-ui/react`,只有一份实现;记录页和记录弹窗只改了 import,它们原有测试一条未改、全绿。容器只在打开时认领自己那次写入,用 `submitHandler` 的容器不认领。回滚即 revert 本 PR。 - 先看:`ui/packages/react/src/record-save-feedback.ts`(搬下来的认领注册表与新的 `parkAnswerWarnings`);`ui/packages/plugin-form/src/ObjectFormContainer.tsx`(`useContainerSaveFeedback` 里的认领与 `toastRecordSave`);`ui/packages/app-shell/src/providers/writeWarningToast.ts`(`emitWriteWarning` 改走 `parkAnswerWarnings`)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-form/src/__tests__/containerSaveToastCarriesWarnings-28216.test.tsx packages/react/src/record-save-feedback.test.ts`,预期 2 个文件、17 个测试全绿。 - 需要你做的:无,席位落地。 ## What changes **One home for the claim** (`ui/packages/react/src/record-save-feedback.ts`). The claim registry the record page and the record dialogs use (#27999) lived in `@object-ui/app-shell`'s write-warning channel, which `@object-ui/plugin-form` cannot import (app-shell depends on plugin-form). It moves, unchanged in behaviour, to `@object-ui/react`, which both packages already depend on, beside the data-invalidation bus, the other module-level seam between writers and readers. Moved as they were: `claimAnswerWarnings`, the matching rule, `answerWarningLines`, `toastRecordSave` and its sink type. New: `parkAnswerWarnings(adapter, ev, show)`, the channel's side of the same registry, which parks an event's warnings on the claim that names it and answers whether one did. The event type is structural (`AnswerWarningsEvent`) because `@object-ui/react` sits below `@object-ui/data-objectstack`; `WriteWarningEvent` satisfies it. Why this home and not the other options the order lists: an injected seam (the container accepting a claim from its host) would keep the registry in app-shell but leave a page block outside the Console with two toasts, and needs a context type in a lower package anyway; `@object-ui/i18n` (next to `recordSaveMessage`) would put a write-coordination registry in a translation package; `@object-ui/core` holds no React-facing runtime seams. `@object-ui/react` is the lowest package all three save surfaces and the channel share with a precedent of this shape. No dependency edge is added: `react` depends on `core`, `i18n`, `types` and `@objectstack/spec` as before. **The channel** (`ui/packages/app-shell/src/providers/writeWarningToast.ts`). `emitWriteWarning` parks through `parkAnswerWarnings`, still synchronously before its first `await`, and toasts the warnings itself only when no claim took them. The stripped-fields toast is never claimed, as before. Its claim half is deleted, not re-exported, so there is one spelling. **The callers** (`RecordFormPage.tsx`, `AppContent.tsx`). Imports re-pointed to `@object-ui/react`; nothing else changes. Their #27999 tests are untouched and green. **The container** (`ui/packages/plugin-form/src/ObjectFormContainer.tsx`). `useContainerSaveFeedback` opens a claim while the container is open (`schema.open !== false`) and makes the write itself (an adapter, no `submitHandler`, `create` or `edit` with a `recordId`): a create of its object, or an update of its record. On success it takes its write's warnings first, before the label lookup awaits, and raises `toastRecordSave`: the record-named message (or a declared `successMessage`) on the warning variant with the warnings one per line, the success variant when there are none. Closing or unmounting releases the claim, and anything it still parks (a write it did not make) gets the channel's toast. The effect is keyed on primitives and the adapter, as `AppContent`'s is (ui/AGENTS.md #10). ## Tests New: - `ui/packages/plugin-form/src/__tests__/containerSaveToastCarriesWarnings-28216.test.tsx` (14, drawer and modal each): edit and create show one warning-variant toast naming the record with the warning, and the channel shows nothing; a declared `successMessage` carries them too; CONTROL: warnings about another record keep the channel toast and the save toast stays the plain success one; CONTROL: a `submitHandler` container claims nothing; CONTROL: a closed container claims nothing; closing the container shows what its claim still parks. The channel is stood in for by `parkAnswerWarnings`, called inside the write as the adapter emits its event (plugin-form cannot import app-shell). - `ui/packages/react/src/record-save-feedback.test.ts` (3): park on a matching claim and take distinct lines; CONTROL: no park for another adapter, record or object, or after release; `toastRecordSave` picks the variant. Updated: `writeWarningToast.test.ts` imports `claimAnswerWarnings` from `@object-ui/react`; its assertions are unchanged. **Ablation** (through `scripts/ablation-replace.mjs`, mutation proved on disk, restored to the HEAD blob): the container's claim effect returning before it claims → the new file 8 failed / 6 passed (the four one-toast rows per form type red, the controls green, as predicted before the run); restored, blob `4f849d2d` = HEAD. ## Acceptance notes - Behaviour of the record page and the record dialogs is unchanged: same registry, same matching rule, same toast; their files differ only in an import line. - The container claims only while open, so a drawer mounted closed on a page does not hold the warnings of a create of the same object made elsewhere. While two surfaces claim the same scope at once (a page drawer open over the Console's own dialog for the same object), the earlier claim wins, which is the registry's existing rule, not new here. - Not measured live in a browser; the chain is pinned through the real `DrawerForm` / `ModalForm` submit path in jsdom. ## Verification <details><summary>Local runs</summary> Head `0bd8234e`, on `main` `f40b777d`. `main` has moved 6 commits since; none touches a file in this diff and `git merge-tree` with `origin/main` is clean. - `pnpm turbo run build --filter='@object-ui/app-shell^...' --concurrency=2` (the dependency closure, under the verify lock) → `Tasks: 64 successful, 64 total`, VERDICT command-exit 0. - `type-check` of `@object-ui/react`, `@object-ui/plugin-form`, `@object-ui/app-shell` (`tsc --noEmit` and `tsc -p tsconfig.test.json`) → exit 0 each. - Under the verify lock: `pnpm --dir ui exec vitest run packages/react/ packages/plugin-form/` plus the 129 app-shell and console test files that name `writeWarningToast`, `RecordFormPage`, `AppContent`, `toastRecordSave`, `claimAnswerWarnings` or `ObjectFormContainer` → `Test Files 474 passed (474)`, `Tests 4903 passed | 1 skipped (4904)`, VERDICT command-exit 0. - The six toast files (the two new ones, `containerSaveToastNamesRecord-27453`, `writeWarningToast`, `RecordFormPage.saveToastCarriesWarnings-27999`, `AppContent.recordSaveOneToast-27999`) → 6 files, 65 tests passed. - eslint on the 9 changed `ts` / `tsx` files under `ui/` → 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 `0bd8234e` → 34 commands, each run with stdin from /dev/null and its exit code captured before any pipe; `--ran` reconcile: `34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN`, exit 0. ``` 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: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_01L9qibvQRyTQaTC3jT6F19i)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 898e439 commit ba5c754

10 files changed

Lines changed: 508 additions & 131 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/react': patch
3+
'@object-ui/plugin-form': patch
4+
'@object-ui/app-shell': patch
5+
---
6+
7+
fix(plugin-form): a drawer or modal `object-form` save that answers warnings shows one toast, the record-named message carrying them
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
A save from an `object-form` block with `formType: 'drawer'` or `'modal'` that succeeded with advisory warnings (a warning-severity validation rule, for example) showed two toasts: the record-named success one and the write-warning channel's own "Saved — with warnings". The record page and the Console's record dialogs already showed one. The container now does the same: while it is open it claims the answer warnings of the write it makes (its record's update, or a create of its object), and its save toast carries them on the warning variant, one per line, whether the message is the record-named one or a declared `successMessage`. A write the container did not make keeps the channel's toast, and so does a container with a `submitHandler`, which writes nothing itself.
13+
14+
- **`@object-ui/react`**: new exports `claimAnswerWarnings`, `parkAnswerWarnings`, `answerWarningLines`, `toastRecordSave` and the types `AnswerWarningsEvent`, `AnswerWarningScope`, `AnswerWarningClaim` and `RecordSaveSink`. They are the one claim registry every record save surface and the write-warning channel share, moved here from `@object-ui/app-shell`, where they were internal, so `@object-ui/plugin-form` can reach them.
15+
- **`@object-ui/app-shell`**: the write-warning channel, the record page and the record dialogs read the claim from `@object-ui/react`. Their behaviour is unchanged.

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,13 @@ 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 } from '@object-ui/react';
17+
import { useActionRunner, useGlobalUndo, useMutationInvalidationBridge, notifyDataChanged, FilterScopeProvider, resolveApprovalLock, claimAnswerWarnings, toastRecordSave, type AnswerWarningClaim } 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';
2121
import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth';
2222
import { useMetadata } from '../providers/MetadataProvider.js';
2323
import { useAdapter } from '../providers/AdapterProvider.js';
24-
import { claimAnswerWarnings, toastRecordSave, type AnswerWarningClaim } from '../providers/writeWarningToast.js';
2524
import { usePreviewDrafts } from '../preview/PreviewModeContext.js';
2625
import { PreviewDraftEmptyState } from '../preview/PreviewDraftEmptyState.js';
2726
import {

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@
3636

3737
import { describe, it, expect, vi, beforeEach } from 'vitest';
3838
import { DroppedFieldsEventSchema } from '@objectstack/spec/data';
39-
import { claimAnswerWarnings, emitWriteWarning, type WriteWarningSink } from './writeWarningToast';
39+
import { emitWriteWarning, type WriteWarningSink } from './writeWarningToast';
40+
import { claimAnswerWarnings } from '@object-ui/react';
4041
import { ObjectStackAdapter } from '@object-ui/data-objectstack';
4142
import { createI18n, loadBuiltInLocale, type TranslateFn } from '@object-ui/i18n';
4243
import type { DroppedFieldsEvent, WriteWarningEvent } from '@object-ui/data-objectstack';

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

Lines changed: 12 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88

99
/**
1010
* Turning a write-warning (objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455) into the message the user
11-
* reads. Lives apart from `AdapterProvider` — and, deliberately, imports NOTHING
12-
* that renders — so the wording, the label resolution and the reason-branching
13-
* can be exercised directly.
11+
* reads. Lives apart from `AdapterProvider` — and, deliberately, imports no
12+
* toaster (the caller owns the sink) — so the wording, the label resolution and
13+
* the reason-branching can be exercised directly. The record save surfaces'
14+
* claim on answer warnings is `@object-ui/react`'s `record-save-feedback`,
15+
* shared with the `object-form` drawer and modal (objectstack-ai/objectstack#28216).
1416
*
1517
* @module providers/writeWarningToast
1618
*/
@@ -22,6 +24,7 @@ import type {
2224
WriteWarningEvent,
2325
} from '@object-ui/data-objectstack';
2426
import type { TranslateFn } from '@object-ui/i18n';
27+
import { answerWarningLines, parkAnswerWarnings } from '@object-ui/react';
2528

2629
/**
2730
* i18next's `t`, narrowed to what this module uses — RE-EXPORTED from its one
@@ -239,8 +242,9 @@ function lineFor(reason: DroppedFieldsNotice['reason']): StrippedLine {
239242
* did.
240243
*
241244
* Except for a write a record save surface reports itself
242-
* (objectstack-ai/objectstack#27999): when an open {@link claimAnswerWarnings}
243-
* claim on `adapter` matches the event, its `warnings` are parked on that claim
245+
* (objectstack-ai/objectstack#27999): when an open `claimAnswerWarnings`
246+
* claim on `adapter` (`@object-ui/react`, objectstack-ai/objectstack#28216)
247+
* matches the event, its `warnings` are parked on that claim
244248
* instead, and the surface's own record-named save toast carries them. The
245249
* match is decided SYNCHRONOUSLY, before this function's first `await`: the
246250
* adapter emits the event inside the write, before the write's promise settles,
@@ -257,19 +261,10 @@ export async function emitWriteWarning(
257261
): Promise<void> {
258262
// Before the first `await`: see the claim paragraph above.
259263
const messages = answerWarningLines(ev);
260-
const claim = messages.length > 0 && adapter ? matchingClaim(adapter, ev) : undefined;
261-
if (claim) claim.parked.push({ ev, show: () => emitAnswerWarnings(messages, t, sink) });
264+
const claimed = messages.length > 0 && !!adapter
265+
&& parkAnswerWarnings(adapter, ev, () => emitAnswerWarnings(messages, t, sink));
262266
await emitStrippedFields(ev, t, adapter, fieldLabel, sink);
263-
if (!claim) emitAnswerWarnings(messages, t, sink);
264-
}
265-
266-
/** The write answer's advisory `warnings`, one entry per distinct message. */
267-
function answerWarningLines(ev: WriteWarningEvent): string[] {
268-
const messages: string[] = [];
269-
for (const w of ev.warnings ?? []) {
270-
if (w.message && !messages.includes(w.message)) messages.push(w.message);
271-
}
272-
return messages;
267+
if (!claimed) emitAnswerWarnings(messages, t, sink);
273268
}
274269

275270
/**
@@ -286,111 +281,6 @@ function emitAnswerWarnings(messages: string[], t: TranslateFn, sink: WriteWarni
286281
);
287282
}
288283

289-
/**
290-
* The writes a record save surface reports itself: the record form an open
291-
* overlay or page saves (objectstack-ai/objectstack#27999). An update names
292-
* the record it writes; a create's record id is only known from its answer.
293-
*/
294-
export interface AnswerWarningScope {
295-
operation: WriteWarningEvent['operation'];
296-
resource: string;
297-
id?: string | number;
298-
}
299-
300-
/** A record save surface's hold on the answer warnings of its own write. */
301-
export interface AnswerWarningClaim {
302-
/**
303-
* The distinct warning messages of the write that answered `saved`, removed
304-
* from the claim. Every other event the claim parked is shown on the
305-
* channel's own toast at once, so a write the surface did not make keeps
306-
* the toast it always had.
307-
*/
308-
take(saved: unknown): string[];
309-
/** Close the claim; whatever it still parks is shown on the channel's toast. */
310-
release(): void;
311-
}
312-
313-
interface ParkedAnswerWarnings {
314-
ev: WriteWarningEvent;
315-
show: () => void;
316-
}
317-
318-
interface ClaimEntry {
319-
scope: AnswerWarningScope;
320-
parked: ParkedAnswerWarnings[];
321-
}
322-
323-
/** Open claims per adapter instance: the one `AdapterProvider` subscribes to. */
324-
const CLAIMS = new WeakMap<object, Set<ClaimEntry>>();
325-
326-
const sameId = (a: unknown, b: unknown): boolean =>
327-
a === undefined || a === null ? b === undefined || b === null : String(a) === String(b);
328-
329-
function matchingClaim(adapter: object, ev: WriteWarningEvent): ClaimEntry | undefined {
330-
for (const entry of CLAIMS.get(adapter) ?? []) {
331-
const { scope } = entry;
332-
if (scope.operation !== ev.operation || scope.resource !== ev.resource) continue;
333-
if (scope.operation === 'update' && !sameId(scope.id, ev.id)) continue;
334-
return entry;
335-
}
336-
return undefined;
337-
}
338-
339-
/**
340-
* Claim the answer warnings of the writes `scope` names on `adapter`, for as
341-
* long as the surface that will report them is open. The surface reads its
342-
* write's warnings with {@link AnswerWarningClaim.take} when the save succeeds
343-
* and closes the claim with {@link AnswerWarningClaim.release} when it closes;
344-
* no parked warning is dropped by either.
345-
*/
346-
export function claimAnswerWarnings(adapter: object, scope: AnswerWarningScope): AnswerWarningClaim {
347-
const entry: ClaimEntry = { scope, parked: [] };
348-
let open = CLAIMS.get(adapter);
349-
if (!open) CLAIMS.set(adapter, (open = new Set()));
350-
open.add(entry);
351-
const showAll = (list: ParkedAnswerWarnings[]) => {
352-
for (const p of list) p.show();
353-
};
354-
return {
355-
take(saved) {
356-
const record = saved && typeof saved === 'object' ? (saved as { id?: unknown; _id?: unknown }) : undefined;
357-
const savedId = record?.id ?? record?._id;
358-
// An update's parked events already name its record; a create's are its
359-
// own only when the answer's record id is the saved record's.
360-
const own = entry.parked.filter((p) => scope.operation === 'update' || sameId(p.ev.id, savedId));
361-
const others = entry.parked.filter((p) => !own.includes(p));
362-
entry.parked = [];
363-
showAll(others);
364-
const messages: string[] = [];
365-
for (const p of own) {
366-
for (const m of answerWarningLines(p.ev)) if (!messages.includes(m)) messages.push(m);
367-
}
368-
return messages;
369-
},
370-
release() {
371-
open?.delete(entry);
372-
const left = entry.parked;
373-
entry.parked = [];
374-
showAll(left);
375-
},
376-
};
377-
}
378-
379-
/** Where a record save toast goes. Structurally satisfied by sonner's `toast`. */
380-
export interface RecordSaveSink extends WriteWarningSink {
381-
success(title: string): void;
382-
}
383-
384-
/**
385-
* The one toast of a record save (objectstack-ai/objectstack#27999): the
386-
* surface's record-named message, carrying its write's advisory warnings one
387-
* per line on the warning variant when there are any.
388-
*/
389-
export function toastRecordSave(sink: RecordSaveSink, message: string, warnings: readonly string[] = []): void {
390-
if (warnings.length > 0) sink.warning(message, { description: warnings.join('\n') });
391-
else sink.success(message);
392-
}
393-
394284
/** The stripped-fields toast — the channel's original and unchanged message. */
395285
async function emitStrippedFields(
396286
ev: WriteWarningEvent,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import { toast } from 'sonner';
3737
import { useObjectTranslation, useObjectLabel, recordSaveMessage } from '@object-ui/i18n';
3838
import { useMetadata } from '../providers/MetadataProvider.js';
3939
import { useAdapter } from '../providers/AdapterProvider.js';
40-
import { claimAnswerWarnings, toastRecordSave, type AnswerWarningClaim } from '../providers/writeWarningToast.js';
40+
import { claimAnswerWarnings, toastRecordSave, type AnswerWarningClaim } from '@object-ui/react';
4141
import {
4242
ExpressionProvider,
4343
createExpressionEvaluator,

‎ui/packages/plugin-form/src/ObjectFormContainer.tsx‎

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,17 @@
3131
* (`recordSaveMessage`, `Task "Quarterly review" updated`), before the author's
3232
* `onSuccess` runs. A host that mounts `DrawerForm` / `ModalForm` itself owns
3333
* its own feedback, so it never passes through here and is never toasted twice.
34+
* It is the save's ONE toast (objectstack-ai/objectstack#28216): while the
35+
* container is open it claims the answer warnings of the write it makes, as
36+
* the record page and the console's record dialogs do, and the record-named
37+
* toast carries them instead of the write-warning channel raising a second.
3438
*/
3539

36-
import React, { useCallback } from 'react';
40+
import React, { useCallback, useEffect, useRef } from 'react';
3741
import type { ObjectFormSchema } from '@object-ui/types';
3842
import { toast } from '@object-ui/components';
3943
import { createSafeTranslation, recordSaveMessage, useObjectLabel } from '@object-ui/i18n';
44+
import { claimAnswerWarnings, toastRecordSave, type AnswerWarningClaim } from '@object-ui/react';
4045
import { usePreparedFormSchema, ObjectForm, type ObjectFormComponentProps } from './ObjectForm';
4146
import { DrawerForm } from './DrawerForm';
4247
import { ModalForm } from './ModalForm';
@@ -83,16 +88,40 @@ const useSaveFeedbackTranslation = createSafeTranslation(
8388
* author's `onSuccess`. Only a write the container made is toasted — a
8489
* `submitHandler` owns its own persistence and feedback, and an adapter-less
8590
* inline-fields collector wrote nothing (its `onSuccess` IS the write).
91+
*
92+
* The toast carries the write's advisory warnings: while the container is
93+
* open, the write-warning channel parks the answer warnings of the write it
94+
* makes (this record's update, or a create of this object) on the claim below
95+
* instead of toasting them. Keyed on primitives (ui/AGENTS.md #10).
8696
*/
8797
function useContainerSaveFeedback(
8898
schema: ReturnType<typeof usePreparedFormSchema>['schema'],
8999
dataSource: ObjectFormComponentProps['dataSource'],
90100
): (saved: unknown) => Promise<void> {
91101
const { t } = useSaveFeedbackTranslation();
92102
const { objectLabel } = useObjectLabel();
93-
const { onSuccess, submitHandler, objectName, mode, successMessage } = schema;
103+
const { onSuccess, submitHandler, objectName, mode, recordId, successMessage } = schema;
104+
const isOpen = schema.open !== false;
105+
const writes = !!dataSource && !submitHandler && (mode === 'create' || mode === 'edit');
106+
const answerWarningClaim = useRef<AnswerWarningClaim | null>(null);
107+
useEffect(() => {
108+
if (!dataSource || !writes || !isOpen || !objectName) return;
109+
if (mode === 'edit' && !recordId) return;
110+
const claim = claimAnswerWarnings(dataSource, mode === 'edit'
111+
? { operation: 'update', resource: objectName, id: recordId }
112+
: { operation: 'create', resource: objectName });
113+
answerWarningClaim.current = claim;
114+
return () => {
115+
if (answerWarningClaim.current === claim) answerWarningClaim.current = null;
116+
claim.release();
117+
};
118+
}, [dataSource, writes, isOpen, mode, objectName, recordId]);
119+
94120
return useCallback(async (saved: unknown) => {
95-
if (dataSource && !submitHandler && (mode === 'create' || mode === 'edit')) {
121+
if (dataSource && writes) {
122+
// Taken first, so nothing the label lookup below awaits can come
123+
// between the write and its claim.
124+
const warnings = answerWarningClaim.current?.take(saved) ?? [];
96125
let message = successMessage;
97126
if (!message) {
98127
// The adapter caches the definition the form body already read.
@@ -104,10 +133,10 @@ function useContainerSaveFeedback(
104133
mode,
105134
});
106135
}
107-
toast.success(message);
136+
toastRecordSave(toast, message, warnings);
108137
}
109138
await onSuccess?.(saved);
110-
}, [dataSource, submitHandler, mode, successMessage, objectName, onSuccess, objectLabel, t]);
139+
}, [writes, dataSource, mode, successMessage, objectName, onSuccess, objectLabel, t]);
111140
}
112141

113142
const ObjectFormContainerBody: React.FC<ObjectFormComponentProps> = ({

0 commit comments

Comments
 (0)