Skip to content

Commit 03069ca

Browse files
fix(ui): object-form drawer and modal saves toast the record-named save message (#28204)
domain:ui#1 · os-tesla · session_01L9qibzC6RxMfB59EsgAoNN <!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao4bEiUw69ndedqMFX4DU3cu)_ Fixes #27453 Clause-②: yes (widening: @object-ui/i18n gains the public export recordSaveMessage; no accept set moves) Metadata-protocol: none ## 维护者速读 - 改了什么:`object-form` 块以抽屉或弹窗打开(`formType: 'drawer' | 'modal'`,含 React 页面里 `formType="drawer"` 的 `ObjectForm`)保存成功后,现在弹出点名记录的成功提示,如 `Task "Fix login" updated`,与控制台记录对话框一致;提示文案改由 `@object-ui/i18n` 新导出的 `recordSaveMessage` 统一生成,控制台的两份手抄副本一并换成它。 - 为什么改:showcase 的任务台页面在抽屉里改任务、在弹窗里建任务,写入成功却没有任何反馈,违反「每次落地的写都要有点名记录的反馈」;React 页面的作用域里拿不到 toast,页面自己补不上,所以按分诊在平台补。 - 风险与回滚:只在块的抽屉/弹窗容器里加提示;控制台自己挂的 `ModalForm`、动作弹窗、设计器表单都不经过这个容器,不会双重提示(有用例钉住);带 `submitHandler` 的表单不提示;回滚即 revert 本 PR。 - 先看:`ui/packages/plugin-form/src/ObjectFormContainer.tsx` 的 `useContainerSaveFeedback`、`ui/packages/i18n/src/recordSaveMessage.ts`、`AppContent.tsx` 里改为调用共享构造器的 `recordSaveMessage`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-form/src/__tests__/containerSaveToastNamesRecord-27453.test.tsx`,预期 13 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes The triage ruling (6523407987) asked for the console's named-save feedback to become reachable from the platform: the drawer and modal forms raise the same save feedback the record pages do, through one exported helper rather than a second copy of `recordSaveMessage`, with no change to the showcase page. - **`@object-ui/i18n`** adds `recordSaveMessage(t, { label, objectDef, saved, mode })`: the record-named message (`form.createdNamed` / `form.updatedNamed`), or the object-label one (`form.createSuccess` / `form.updateSuccess`) when the ADR-0079 resolver finds nothing nameable (its `Record #ID` floor). It lives in `@object-ui/i18n` because its four keys live in that package's packs, it already depends on `@object-ui/core` (where `getRecordDisplayName` lives), and both `@object-ui/plugin-form` and `@object-ui/app-shell` already depend on it. No new dependency edge. - **`@object-ui/app-shell`**: the console's record dialog (`AppContent`'s `recordSaveMessage`) and the record form page (`RecordFormPage`'s `handleSuccess`) each carried their own copy of that logic; both now call the shared builder. Same keys, same floor, same wording; the #27999 warning claim and `toastRecordSave` are untouched. - **`@object-ui/plugin-form`**: `ObjectFormContainer`, the container the `object-form` block supplies for `formType: 'drawer' | 'modal'` (ADR-0050 Decision 4), now wraps the author's `onSuccess`: when the container itself wrote (it has a data source and no `submitHandler`, in `create` or `edit` mode) it toasts the record-named message, then runs the author's `onSuccess`. A declared `successMessage` is used as written. The object definition is read through `getObjectSchema`, which the adapter has already cached for the form body. `DrawerForm` and `ModalForm` themselves are unchanged, so a host that mounts them directly (the console's record dialog, `useActionModal`, the designer's object and field forms) keeps owning its feedback and is never toasted twice. `examples/app-showcase` is unchanged: its task-desk page's `afterWrite` keeps closing the overlay and reloading, and the toast now comes from the platform. No `packages/spec` edit. ## Tests - `ui/packages/plugin-form/src/__tests__/containerSaveToastNamesRecord-27453.test.tsx` (new, 13 cases, each of the first six run for `drawer` and `modal`): edit toasts `Task "Fix login" updated` before the author's `onSuccess`, which still gets the record; create toasts `Task "Write release notes" created`; a metadata-only block with no `onSuccess` is toasted; a record with nothing nameable keeps `Task created successfully`; a declared `successMessage` is the toast; a `submitHandler` form is not toasted; a directly mounted `DrawerForm` with an `onSuccess` is not toasted. - Existing suites that pin the console wording now run through the shared builder unchanged: `AppContent.recordSaveToastNamesRecord-26172`, `AppContent.recordSaveOneToast-27999` and every `RecordFormPage.*` suite (14 files, 84 tests, all green). **Ablation.** With `ObjectFormContainer`'s `toast.success(message)` replaced by a no-op through `scripts/ablation-replace.mjs` (anchor 1 → 0 on disk), the new suite went 10 red / 3 green: every "toasts" row red, the three "not toasted" rows green, as predicted before the run. Restored by the tool: blob equal to `HEAD`'s, `git diff HEAD` empty. ## Acceptance notes - **Where the toast is raised, and why not inside `DrawerForm` / `ModalForm`.** The ruling allowed either the form variants or a React-page toast hook; a hook would have needed the showcase page to call it, which the ruling rules out. Inside `DrawerForm` / `ModalForm` the toast would have fired for every host that mounts them directly and toasts itself (the console's record dialog through `crud_success`, `useActionModal`), so each would have needed an opt-out. `ObjectFormContainer` is the one place where the `object-form` block is the caller (ADR-0050 Decision 4), so the feedback sits there and the direct hosts are untouched; the last test row pins that. - **Advisory write warnings on this path are not folded into the toast.** The console's dialog and record page claim their write's answer warnings and carry them on the save toast (#27999, `claimAnswerWarnings` in `@object-ui/app-shell`). The block container lives in `@object-ui/plugin-form`, below app-shell, and has no claim, so a warning from a drawer/modal save still arrives on the write-warning channel's own toast, as it did before this change. Noted, not filed. - **`successMessage` on a drawer/modal block was never shown before.** It is now the toast when declared; previously neither container read it. - **Showcase unchanged** (`examples/app-showcase`): its `afterWrite` keeps closing the overlay and bumping the reload counter. Live reproduction against the showcase backend was not run in this container; the behaviour is pinned through the same `ObjectFormContainer` route the React page's `ObjectForm` block takes. - `@object-ui/app-shell` is `private: true`, so the changeset bumps only `@object-ui/i18n` and `@object-ui/plugin-form`; the app-shell edit is a refactor onto the shared builder with no wording change. - **Double-toast survey (e8e2bcb).** Hosts that mount `ModalForm` / `DrawerForm` directly and own their feedback: `AppContent` (record dialog, via `crud_success`), `useActionModal`, the designer's `FieldDesigner` and `ObjectManager`. None of them passes through `ObjectFormContainer`, so none gets a second toast; the last test row pins a direct `DrawerForm` mount. Pages that use the block's container and gain a toast: the showcase task-desk page (drawer edit, modal create) and the showcase renewals page (`ObjectForm formType="drawer"`). Neither raised a toast of its own before, so neither is doubled. ## Verification Head `e8e2bcbd` (the branch merged with `origin/main` `c48cd2c1`; the four incoming commits touch no file under `ui/`). - New suite: 13/13 green. Ablation as described above: 10 red / 3 green, restored to `HEAD`. - `pnpm --dir ui exec vitest run packages/plugin-form/ packages/i18n/`: 306 files. The first run had 2 failures in `residue-namespaces-3546.test.tsx`, which pins `AppContent`'s fallback spelling `t('common.record', { defaultValue: 'Record' })`; the spelling is restored in `AppContent` and that file plus `AppContent.recordSaveToastNamesRecord-26172` re-ran 52/52 green. - `pnpm --dir ui exec vitest run` on `AppContent.recordSaveToastNamesRecord-26172`, `AppContent.recordSaveOneToast-27999` and `packages/app-shell/src/views/RecordFormPage*`: 14 files, 84 tests, green. - `type-check` (with a real `.d.ts` build of the `@object-ui/app-shell^...` closure): `@object-ui/i18n`, `@object-ui/plugin-form`, `@object-ui/app-shell` exit 0. - `eslint --no-inline-config` on the six touched source files: 0 errors. `pnpm --dir ui run check:new-line-citations`: 0 new. - Derived gates on `e8e2bcbd`, every line run with stdin from /dev/null, exit codes on disk, reconciled with `dispatch-gates.mjs --ran`: 34 derived, 34 run, 0 NOT-MEASURED. <details><summary>Derived gates (local, head e8e2bcb)</summary> ```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 ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qiby8nyBCmFZV9NNymGG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 49a078e commit 03069ca

7 files changed

Lines changed: 348 additions & 52 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@object-ui/i18n': patch
3+
'@object-ui/plugin-form': patch
4+
---
5+
6+
Clause-②: yes (widening: @object-ui/i18n gains the public export recordSaveMessage; no accept set moves)
7+
Metadata-protocol: none
8+
9+
A record saved from an `object-form` block's drawer or modal (`formType: 'drawer' | 'modal'`, including `<ObjectForm formType="drawer">` on a React page) now gets the record-named success toast the console's record dialogs give, such as `Task "Fix login" updated` or `Task "Write release notes" created`. A record with nothing nameable keeps the object-label toast, and a declared `successMessage` is used as written. The toast comes before the author's `onSuccess`, so a page whose `onSuccess` only closes the overlay and reloads its list needs no change. A host that mounts `DrawerForm` / `ModalForm` itself, and a form with a `submitHandler`, keeps owning its feedback and is not toasted twice. `@object-ui/i18n` adds `recordSaveMessage(t, { label, objectDef, saved, mode })`, the one builder of that message, which the console's record dialog and record page now share.

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

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@ import { Empty, EmptyTitle, EmptyDescription, Button, DataErrorState } from '@ob
1616
import { toast } from 'sonner';
1717
import { useActionRunner, useGlobalUndo, useMutationInvalidationBridge, notifyDataChanged, FilterScopeProvider, resolveApprovalLock } from '@object-ui/react';
1818
import { useRecordApprovals, openApprovalOf } from '../hooks/useRecordApprovals.js';
19-
import { getRecordDisplayName } from '@object-ui/core';
20-
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
19+
import { useObjectTranslation, useObjectLabel, recordSaveMessage as buildRecordSaveMessage } from '@object-ui/i18n';
2120
import type { AppAccessVerdict, ConnectionState } from '@object-ui/data-objectstack';
2221
import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth';
2322
import { useMetadata } from '../providers/MetadataProvider.js';
@@ -881,28 +880,15 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
881880

882881
// objectstack-ai/objectstack#26172 — a record save names the record it wrote
883882
// (`ux-conventions.write-feedback`): `Task "Quarterly review" created`. The
884-
// name is the saved record's display name from the ONE ADR-0079 resolver,
885-
// read through the object being saved. The resolver's floor (`Record #<id>`,
886-
// or the empty fallback for an id-less row) is not a name, so a record with
887-
// nothing nameable keeps the object-label toast it always had.
888-
const recordSaveMessage = useCallback((saved: any): string => {
889-
const label = formObjectDef ? objectLabel(formObjectDef as any) : t('common.record', { defaultValue: 'Record' });
890-
const resolved = saved && typeof saved === 'object'
891-
? getRecordDisplayName(formObjectDef, saved, { fallback: '' })
892-
: '';
893-
const id = saved?.id ?? saved?._id;
894-
const title = resolved && !(id !== null && id !== undefined && resolved === `Record #${id}`)
895-
? resolved
896-
: undefined;
897-
if (editingRecord) {
898-
return title
899-
? t('form.updatedNamed', { object: label, title, defaultValue: `${label} "${title}" updated` })
900-
: t('form.updateSuccess', { object: label, defaultValue: `${label} updated successfully` });
901-
}
902-
return title
903-
? t('form.createdNamed', { object: label, title, defaultValue: `${label} "${title}" created` })
904-
: t('form.createSuccess', { object: label, defaultValue: `${label} created successfully` });
905-
}, [editingRecord, formObjectDef, objectLabel, t]);
883+
// message is the ONE builder every record save surface shares
884+
// (`recordSaveMessage` in `@object-ui/i18n`), read through the object being
885+
// saved.
886+
const recordSaveMessage = useCallback((saved: any): string => buildRecordSaveMessage(t, {
887+
label: formObjectDef ? objectLabel(formObjectDef as any) : t('common.record', { defaultValue: 'Record' }),
888+
objectDef: formObjectDef,
889+
saved,
890+
mode: editingRecord ? 'edit' : 'create',
891+
}), [editingRecord, formObjectDef, objectLabel, t]);
906892

907893
// objectstack-ai/objectstack#27999 — the open record form reports its own
908894
// write's advisory warnings: while the overlay is open, the write-warning

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

Lines changed: 12 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,7 @@ import { ObjectForm } from '@object-ui/plugin-form';
3434
import { Button, Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
3535
import { ArrowLeft, Building2, Database } from 'lucide-react';
3636
import { toast } from 'sonner';
37-
import { getRecordDisplayName } from '@object-ui/core';
38-
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
37+
import { useObjectTranslation, useObjectLabel, recordSaveMessage } from '@object-ui/i18n';
3938
import { useMetadata } from '../providers/MetadataProvider.js';
4039
import { useAdapter } from '../providers/AdapterProvider.js';
4140
import { claimAnswerWarnings, toastRecordSave, type AnswerWarningClaim } from '../providers/writeWarningToast.js';
@@ -166,15 +165,11 @@ export function RecordFormPage({ mode }: RecordFormPageProps) {
166165
: t('form.editTitle', { object: label });
167166

168167
// objectstack-ai/objectstack#26461 — the save toast names the record it
169-
// wrote (`ux-conventions.write-feedback`), as the dialog paths in
170-
// `console/AppContent.tsx` (`recordSaveMessage`) have since
171-
// objectstack-ai/objectstack#26172: `Task "Quarterly review" created`. Same
172-
// resolver, same floor, same keys. The name is the saved record's display
173-
// name from the ONE ADR-0079 resolver, read through this page's object;
174-
// `ObjectForm` hands the write's result to `onSuccess`. The resolver's floor
175-
// (`Record #<id>`, or the empty fallback for an id-less row) is not a name,
176-
// so a record with nothing nameable keeps the object-label toast. The two
177-
// named keys are in all ten packs, so they pass bare like every key above.
168+
// wrote (`ux-conventions.write-feedback`): `Task "Quarterly review" created`.
169+
// The message is the ONE builder every record save surface shares
170+
// (`recordSaveMessage` in `@object-ui/i18n`), read through this page's
171+
// object; `ObjectForm` hands the write's result to `onSuccess`. Its keys are
172+
// in all ten packs, so they pass bare like every key above.
178173
//
179174
// objectstack-ai/objectstack#27999 — and it is the save's ONE toast: while
180175
// this page is open, the write-warning channel parks the answer warnings of
@@ -197,22 +192,12 @@ export function RecordFormPage({ mode }: RecordFormPageProps) {
197192

198193
const handleSuccess = useCallback((saved?: any) => {
199194
const warnings = answerWarningClaim.current?.take(saved) ?? [];
200-
const resolved = saved && typeof saved === 'object'
201-
? getRecordDisplayName(objectDef, saved, { fallback: '' })
202-
: '';
203-
const id = saved?.id ?? saved?._id;
204-
const title = resolved && !(id !== null && id !== undefined && resolved === `Record #${id}`)
205-
? resolved
206-
: undefined;
207-
if (mode === 'create') {
208-
toastRecordSave(toast, title
209-
? t('form.createdNamed', { object: label, title })
210-
: t('form.createSuccess', { object: label }), warnings);
211-
} else {
212-
toastRecordSave(toast, title
213-
? t('form.updatedNamed', { object: label, title })
214-
: t('form.updateSuccess', { object: label }), warnings);
215-
}
195+
toastRecordSave(toast, recordSaveMessage(t, {
196+
label,
197+
objectDef,
198+
saved,
199+
mode,
200+
}), warnings);
216201
goBack();
217202
}, [mode, t, label, objectDef, goBack]);
218203

‎ui/packages/i18n/src/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ export type { TranslateFn } from './translateFn.js';
8282
// Convention-based object/field label i18n
8383
export { useObjectLabel, useSafeFieldLabel } from './useObjectLabel.js';
8484

85+
// The record-named save message every record save surface toasts.
86+
export { recordSaveMessage, type RecordSaveMessageInput } from './recordSaveMessage.js';
87+
8588
// Locale packs — ⛔ ONE catalogue is statically re-exported here, not ten
8689
// (objectui#7479).
8790
//
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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+
* The message of a record save (`ux-conventions.write-feedback`): the save
11+
* names the record it wrote — `Task "Quarterly review" created`.
12+
*
13+
* ONE builder for every save surface: the console's record dialogs, the
14+
* record form page and the `object-form` block's drawer and modal containers.
15+
* It lives here, below both `@object-ui/app-shell` and
16+
* `@object-ui/plugin-form`, because its four keys live in this package's packs
17+
* and both of those packages already depend on it.
18+
*
19+
* @module recordSaveMessage
20+
*/
21+
22+
import { getRecordDisplayName } from '@object-ui/core';
23+
import type { TranslateFn } from './translateFn.js';
24+
25+
export interface RecordSaveMessageInput {
26+
/** The singular label of the saved record's object, already translated. */
27+
label: string;
28+
/** The definition of the object being saved; may be absent or partial. */
29+
objectDef: unknown;
30+
/** The record the write answered with. */
31+
saved: unknown;
32+
/** `create` for an insert, `edit` for an update. */
33+
mode: 'create' | 'edit';
34+
}
35+
36+
/**
37+
* The record-named save message, or the object-label message when the record
38+
* has nothing nameable.
39+
*
40+
* The name is the saved record's display name from the ONE ADR-0079 resolver,
41+
* read through the object being saved. The resolver's floor (`Record #<id>`,
42+
* or the empty fallback for an id-less row) is not a name, so such a record
43+
* keeps the object-label message.
44+
*/
45+
export function recordSaveMessage(t: TranslateFn, input: RecordSaveMessageInput): string {
46+
const { label, objectDef, saved, mode } = input;
47+
const resolved = saved && typeof saved === 'object'
48+
? getRecordDisplayName(objectDef, saved, { fallback: '' })
49+
: '';
50+
const record = saved && typeof saved === 'object' ? (saved as { id?: unknown; _id?: unknown }) : undefined;
51+
const id = record?.id ?? record?._id;
52+
const title = resolved && !(id !== null && id !== undefined && resolved === `Record #${id}`)
53+
? resolved
54+
: undefined;
55+
if (mode === 'create') {
56+
return title
57+
? t('form.createdNamed', { object: label, title })
58+
: t('form.createSuccess', { object: label });
59+
}
60+
return title
61+
? t('form.updatedNamed', { object: label, title })
62+
: t('form.updateSuccess', { object: label });
63+
}

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

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,19 @@
2424
* Both maps prepare the schema through `usePreparedFormSchema`, the one
2525
* preparation `ObjectForm` uses, so the labels, field-level security and
2626
* `{ group }` sections a container draws are the ones the layouts draw.
27+
*
28+
* As the caller, the block also gives the save its feedback
29+
* (`ux-conventions.write-feedback`): a write the container makes toasts the
30+
* record-named message the console's record dialogs and record page toast
31+
* (`recordSaveMessage`, `Task "Quarterly review" updated`), before the author's
32+
* `onSuccess` runs. A host that mounts `DrawerForm` / `ModalForm` itself owns
33+
* its own feedback, so it never passes through here and is never toasted twice.
2734
*/
2835

29-
import React from 'react';
36+
import React, { useCallback } from 'react';
3037
import type { ObjectFormSchema } from '@object-ui/types';
38+
import { toast } from '@object-ui/components';
39+
import { createSafeTranslation, recordSaveMessage, useObjectLabel } from '@object-ui/i18n';
3140
import { usePreparedFormSchema, ObjectForm, type ObjectFormComponentProps } from './ObjectForm';
3241
import { DrawerForm } from './DrawerForm';
3342
import { ModalForm } from './ModalForm';
@@ -54,11 +63,59 @@ export const ObjectFormContainer: React.FC<ObjectFormComponentProps> = ({ schema
5463
</RecordFieldAccessForForm>
5564
);
5665

66+
/**
67+
* The save message's copy. Falls back to the `en` pack's values when no i18n
68+
* provider is mounted, so a provider-less host toasts English, not raw keys.
69+
*/
70+
const useSaveFeedbackTranslation = createSafeTranslation(
71+
{
72+
'form.createdNamed': '{{object}} "{{title}}" created',
73+
'form.updatedNamed': '{{object}} "{{title}}" updated',
74+
'form.createSuccess': '{{object}} created successfully',
75+
'form.updateSuccess': '{{object}} updated successfully',
76+
'common.record': 'Record',
77+
},
78+
'form.createdNamed',
79+
);
80+
81+
/**
82+
* The container's `onSuccess`: toast the save, then hand the record to the
83+
* author's `onSuccess`. Only a write the container made is toasted — a
84+
* `submitHandler` owns its own persistence and feedback, and an adapter-less
85+
* inline-fields collector wrote nothing (its `onSuccess` IS the write).
86+
*/
87+
function useContainerSaveFeedback(
88+
schema: ReturnType<typeof usePreparedFormSchema>['schema'],
89+
dataSource: ObjectFormComponentProps['dataSource'],
90+
): (saved: unknown) => Promise<void> {
91+
const { t } = useSaveFeedbackTranslation();
92+
const { objectLabel } = useObjectLabel();
93+
const { onSuccess, submitHandler, objectName, mode, successMessage } = schema;
94+
return useCallback(async (saved: unknown) => {
95+
if (dataSource && !submitHandler && (mode === 'create' || mode === 'edit')) {
96+
let message = successMessage;
97+
if (!message) {
98+
// The adapter caches the definition the form body already read.
99+
const objectDef = await Promise.resolve(dataSource.getObjectSchema?.(objectName)).catch(() => undefined);
100+
message = recordSaveMessage(t, {
101+
label: objectDef?.name ? objectLabel(objectDef) : t('common.record'),
102+
objectDef,
103+
saved,
104+
mode,
105+
});
106+
}
107+
toast.success(message);
108+
}
109+
await onSuccess?.(saved);
110+
}, [dataSource, submitHandler, mode, successMessage, objectName, onSuccess, objectLabel, t]);
111+
}
112+
57113
const ObjectFormContainerBody: React.FC<ObjectFormComponentProps> = ({
58114
schema: rawSchema,
59115
dataSource,
60116
}) => {
61117
const { schema, tSec } = usePreparedFormSchema(rawSchema, dataSource);
118+
const handleSuccess = useContainerSaveFeedback(schema, dataSource);
62119

63120
if (schema.formType === 'drawer') {
64121
// `layout` passes through unfolded: it is `vertical | horizontal` on both
@@ -88,6 +145,7 @@ const ObjectFormContainerBody: React.FC<ObjectFormComponentProps> = ({
88145
})),
89146
open: schema.open,
90147
onOpenChange: schema.onOpenChange,
148+
onSuccess: handleSuccess,
91149
drawerSide: schema.drawerSide,
92150
drawerWidth: schema.drawerWidth,
93151
}}
@@ -126,6 +184,7 @@ const ObjectFormContainerBody: React.FC<ObjectFormComponentProps> = ({
126184
})),
127185
open: schema.open,
128186
onOpenChange: schema.onOpenChange,
187+
onSuccess: handleSuccess,
129188
modalSize: schema.modalSize,
130189
modalCloseButton: schema.modalCloseButton,
131190
}}

0 commit comments

Comments
 (0)