From 8ef99c1871f8ba2c2e56d630ffb501769597100e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:19:36 +0000 Subject: [PATCH 1/4] fix(ui): toast the record-named save message from object-form drawer and modal saves The object-form block's drawer and modal containers now give a landed write the record-named toast the console's record dialogs and record page give, through one shared builder (recordSaveMessage in @object-ui/i18n) that replaces the two copies in app-shell. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qiby8nyBCmFZV9NNymGG --- .../app-shell/src/console/AppContent.tsx | 34 +--- .../app-shell/src/views/RecordFormPage.tsx | 39 ++-- ui/packages/i18n/src/index.ts | 3 + ui/packages/i18n/src/recordSaveMessage.ts | 63 ++++++ .../plugin-form/src/ObjectFormContainer.tsx | 61 +++++- ...ntainerSaveToastNamesRecord-27453.test.tsx | 190 ++++++++++++++++++ 6 files changed, 338 insertions(+), 52 deletions(-) create mode 100644 ui/packages/i18n/src/recordSaveMessage.ts create mode 100644 ui/packages/plugin-form/src/__tests__/containerSaveToastNamesRecord-27453.test.tsx diff --git a/ui/packages/app-shell/src/console/AppContent.tsx b/ui/packages/app-shell/src/console/AppContent.tsx index 671d52d9b18..66d352b3b7b 100644 --- a/ui/packages/app-shell/src/console/AppContent.tsx +++ b/ui/packages/app-shell/src/console/AppContent.tsx @@ -16,8 +16,7 @@ import { Empty, EmptyTitle, EmptyDescription, Button, DataErrorState } from '@ob import { toast } from 'sonner'; import { useActionRunner, useGlobalUndo, useMutationInvalidationBridge, notifyDataChanged, FilterScopeProvider, resolveApprovalLock } from '@object-ui/react'; import { useRecordApprovals, openApprovalOf } from '../hooks/useRecordApprovals.js'; -import { getRecordDisplayName } from '@object-ui/core'; -import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; +import { useObjectTranslation, useObjectLabel, recordSaveMessage as buildRecordSaveMessage } from '@object-ui/i18n'; import type { AppAccessVerdict, ConnectionState } from '@object-ui/data-objectstack'; import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth'; import { useMetadata } from '../providers/MetadataProvider.js'; @@ -881,28 +880,15 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = // objectstack-ai/objectstack#26172 — a record save names the record it wrote // (`ux-conventions.write-feedback`): `Task "Quarterly review" created`. The - // name is the saved record's display name from the ONE ADR-0079 resolver, - // read through the object being saved. The resolver's floor (`Record #`, - // or the empty fallback for an id-less row) is not a name, so a record with - // nothing nameable keeps the object-label toast it always had. - const recordSaveMessage = useCallback((saved: any): string => { - const label = formObjectDef ? objectLabel(formObjectDef as any) : t('common.record', { defaultValue: 'Record' }); - const resolved = saved && typeof saved === 'object' - ? getRecordDisplayName(formObjectDef, saved, { fallback: '' }) - : ''; - const id = saved?.id ?? saved?._id; - const title = resolved && !(id !== null && id !== undefined && resolved === `Record #${id}`) - ? resolved - : undefined; - if (editingRecord) { - return title - ? t('form.updatedNamed', { object: label, title, defaultValue: `${label} "${title}" updated` }) - : t('form.updateSuccess', { object: label, defaultValue: `${label} updated successfully` }); - } - return title - ? t('form.createdNamed', { object: label, title, defaultValue: `${label} "${title}" created` }) - : t('form.createSuccess', { object: label, defaultValue: `${label} created successfully` }); - }, [editingRecord, formObjectDef, objectLabel, t]); + // message is the ONE builder every record save surface shares + // (`recordSaveMessage` in `@object-ui/i18n`), read through the object being + // saved. + const recordSaveMessage = useCallback((saved: any): string => buildRecordSaveMessage(t, { + label: formObjectDef ? objectLabel(formObjectDef as any) : t('common.record'), + objectDef: formObjectDef, + saved, + mode: editingRecord ? 'edit' : 'create', + }), [editingRecord, formObjectDef, objectLabel, t]); // objectstack-ai/objectstack#27999 — the open record form reports its own // write's advisory warnings: while the overlay is open, the write-warning diff --git a/ui/packages/app-shell/src/views/RecordFormPage.tsx b/ui/packages/app-shell/src/views/RecordFormPage.tsx index 40793714af9..fd4ca8ca640 100644 --- a/ui/packages/app-shell/src/views/RecordFormPage.tsx +++ b/ui/packages/app-shell/src/views/RecordFormPage.tsx @@ -34,8 +34,7 @@ import { ObjectForm } from '@object-ui/plugin-form'; import { Button, Empty, EmptyTitle, EmptyDescription } from '@object-ui/components'; import { ArrowLeft, Building2, Database } from 'lucide-react'; import { toast } from 'sonner'; -import { getRecordDisplayName } from '@object-ui/core'; -import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; +import { useObjectTranslation, useObjectLabel, recordSaveMessage } from '@object-ui/i18n'; import { useMetadata } from '../providers/MetadataProvider.js'; import { useAdapter } from '../providers/AdapterProvider.js'; import { claimAnswerWarnings, toastRecordSave, type AnswerWarningClaim } from '../providers/writeWarningToast.js'; @@ -166,15 +165,11 @@ export function RecordFormPage({ mode }: RecordFormPageProps) { : t('form.editTitle', { object: label }); // objectstack-ai/objectstack#26461 — the save toast names the record it - // wrote (`ux-conventions.write-feedback`), as the dialog paths in - // `console/AppContent.tsx` (`recordSaveMessage`) have since - // objectstack-ai/objectstack#26172: `Task "Quarterly review" created`. Same - // resolver, same floor, same keys. The name is the saved record's display - // name from the ONE ADR-0079 resolver, read through this page's object; - // `ObjectForm` hands the write's result to `onSuccess`. The resolver's floor - // (`Record #`, or the empty fallback for an id-less row) is not a name, - // so a record with nothing nameable keeps the object-label toast. The two - // named keys are in all ten packs, so they pass bare like every key above. + // wrote (`ux-conventions.write-feedback`): `Task "Quarterly review" created`. + // The message is the ONE builder every record save surface shares + // (`recordSaveMessage` in `@object-ui/i18n`), read through this page's + // object; `ObjectForm` hands the write's result to `onSuccess`. Its keys are + // in all ten packs, so they pass bare like every key above. // // objectstack-ai/objectstack#27999 — and it is the save's ONE toast: while // this page is open, the write-warning channel parks the answer warnings of @@ -197,22 +192,12 @@ export function RecordFormPage({ mode }: RecordFormPageProps) { const handleSuccess = useCallback((saved?: any) => { const warnings = answerWarningClaim.current?.take(saved) ?? []; - const resolved = saved && typeof saved === 'object' - ? getRecordDisplayName(objectDef, saved, { fallback: '' }) - : ''; - const id = saved?.id ?? saved?._id; - const title = resolved && !(id !== null && id !== undefined && resolved === `Record #${id}`) - ? resolved - : undefined; - if (mode === 'create') { - toastRecordSave(toast, title - ? t('form.createdNamed', { object: label, title }) - : t('form.createSuccess', { object: label }), warnings); - } else { - toastRecordSave(toast, title - ? t('form.updatedNamed', { object: label, title }) - : t('form.updateSuccess', { object: label }), warnings); - } + toastRecordSave(toast, recordSaveMessage(t, { + label, + objectDef, + saved, + mode, + }), warnings); goBack(); }, [mode, t, label, objectDef, goBack]); diff --git a/ui/packages/i18n/src/index.ts b/ui/packages/i18n/src/index.ts index 86b199af164..c951e1b549d 100644 --- a/ui/packages/i18n/src/index.ts +++ b/ui/packages/i18n/src/index.ts @@ -82,6 +82,9 @@ export type { TranslateFn } from './translateFn.js'; // Convention-based object/field label i18n export { useObjectLabel, useSafeFieldLabel } from './useObjectLabel.js'; +// The record-named save message every record save surface toasts. +export { recordSaveMessage, type RecordSaveMessageInput } from './recordSaveMessage.js'; + // Locale packs — ⛔ ONE catalogue is statically re-exported here, not ten // (objectui#7479). // diff --git a/ui/packages/i18n/src/recordSaveMessage.ts b/ui/packages/i18n/src/recordSaveMessage.ts new file mode 100644 index 00000000000..0fb13984b2c --- /dev/null +++ b/ui/packages/i18n/src/recordSaveMessage.ts @@ -0,0 +1,63 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The message of a record save (`ux-conventions.write-feedback`): the save + * names the record it wrote — `Task "Quarterly review" created`. + * + * ONE builder for every save surface: the console's record dialogs, the + * record form page and the `object-form` block's drawer and modal containers. + * It lives here, below both `@object-ui/app-shell` and + * `@object-ui/plugin-form`, because its four keys live in this package's packs + * and both of those packages already depend on it. + * + * @module recordSaveMessage + */ + +import { getRecordDisplayName } from '@object-ui/core'; +import type { TranslateFn } from './translateFn.js'; + +export interface RecordSaveMessageInput { + /** The singular label of the saved record's object, already translated. */ + label: string; + /** The definition of the object being saved; may be absent or partial. */ + objectDef: unknown; + /** The record the write answered with. */ + saved: unknown; + /** `create` for an insert, `edit` for an update. */ + mode: 'create' | 'edit'; +} + +/** + * The record-named save message, or the object-label message when the record + * has nothing nameable. + * + * The name is the saved record's display name from the ONE ADR-0079 resolver, + * read through the object being saved. The resolver's floor (`Record #`, + * or the empty fallback for an id-less row) is not a name, so such a record + * keeps the object-label message. + */ +export function recordSaveMessage(t: TranslateFn, input: RecordSaveMessageInput): string { + const { label, objectDef, saved, mode } = input; + const resolved = saved && typeof saved === 'object' + ? getRecordDisplayName(objectDef, saved, { fallback: '' }) + : ''; + const record = saved && typeof saved === 'object' ? (saved as { id?: unknown; _id?: unknown }) : undefined; + const id = record?.id ?? record?._id; + const title = resolved && !(id !== null && id !== undefined && resolved === `Record #${id}`) + ? resolved + : undefined; + if (mode === 'create') { + return title + ? t('form.createdNamed', { object: label, title }) + : t('form.createSuccess', { object: label }); + } + return title + ? t('form.updatedNamed', { object: label, title }) + : t('form.updateSuccess', { object: label }); +} diff --git a/ui/packages/plugin-form/src/ObjectFormContainer.tsx b/ui/packages/plugin-form/src/ObjectFormContainer.tsx index 0b40e3dd3ee..23f96fc81b2 100644 --- a/ui/packages/plugin-form/src/ObjectFormContainer.tsx +++ b/ui/packages/plugin-form/src/ObjectFormContainer.tsx @@ -24,10 +24,19 @@ * Both maps prepare the schema through `usePreparedFormSchema`, the one * preparation `ObjectForm` uses, so the labels, field-level security and * `{ group }` sections a container draws are the ones the layouts draw. + * + * As the caller, the block also gives the save its feedback + * (`ux-conventions.write-feedback`): a write the container makes toasts the + * record-named message the console's record dialogs and record page toast + * (`recordSaveMessage`, `Task "Quarterly review" updated`), before the author's + * `onSuccess` runs. A host that mounts `DrawerForm` / `ModalForm` itself owns + * its own feedback, so it never passes through here and is never toasted twice. */ -import React from 'react'; +import React, { useCallback } from 'react'; import type { ObjectFormSchema } from '@object-ui/types'; +import { toast } from '@object-ui/components'; +import { createSafeTranslation, recordSaveMessage, useObjectLabel } from '@object-ui/i18n'; import { usePreparedFormSchema, ObjectForm, type ObjectFormComponentProps } from './ObjectForm'; import { DrawerForm } from './DrawerForm'; import { ModalForm } from './ModalForm'; @@ -54,11 +63,59 @@ export const ObjectFormContainer: React.FC = ({ schema ); +/** + * The save message's copy. Falls back to the `en` pack's values when no i18n + * provider is mounted, so a provider-less host toasts English, not raw keys. + */ +const useSaveFeedbackTranslation = createSafeTranslation( + { + 'form.createdNamed': '{{object}} "{{title}}" created', + 'form.updatedNamed': '{{object}} "{{title}}" updated', + 'form.createSuccess': '{{object}} created successfully', + 'form.updateSuccess': '{{object}} updated successfully', + 'common.record': 'Record', + }, + 'form.createdNamed', +); + +/** + * The container's `onSuccess`: toast the save, then hand the record to the + * author's `onSuccess`. Only a write the container made is toasted — a + * `submitHandler` owns its own persistence and feedback, and an adapter-less + * inline-fields collector wrote nothing (its `onSuccess` IS the write). + */ +function useContainerSaveFeedback( + schema: ReturnType['schema'], + dataSource: ObjectFormComponentProps['dataSource'], +): (saved: unknown) => Promise { + const { t } = useSaveFeedbackTranslation(); + const { objectLabel } = useObjectLabel(); + const { onSuccess, submitHandler, objectName, mode, successMessage } = schema; + return useCallback(async (saved: unknown) => { + if (dataSource && !submitHandler && (mode === 'create' || mode === 'edit')) { + let message = successMessage; + if (!message) { + // The adapter caches the definition the form body already read. + const objectDef = await Promise.resolve(dataSource.getObjectSchema?.(objectName)).catch(() => undefined); + message = recordSaveMessage(t, { + label: objectDef?.name ? objectLabel(objectDef) : t('common.record'), + objectDef, + saved, + mode, + }); + } + toast.success(message); + } + await onSuccess?.(saved); + }, [dataSource, submitHandler, mode, successMessage, objectName, onSuccess, objectLabel, t]); +} + const ObjectFormContainerBody: React.FC = ({ schema: rawSchema, dataSource, }) => { const { schema, tSec } = usePreparedFormSchema(rawSchema, dataSource); + const handleSuccess = useContainerSaveFeedback(schema, dataSource); if (schema.formType === 'drawer') { // `layout` passes through unfolded: it is `vertical | horizontal` on both @@ -88,6 +145,7 @@ const ObjectFormContainerBody: React.FC = ({ })), open: schema.open, onOpenChange: schema.onOpenChange, + onSuccess: handleSuccess, drawerSide: schema.drawerSide, drawerWidth: schema.drawerWidth, }} @@ -126,6 +184,7 @@ const ObjectFormContainerBody: React.FC = ({ })), open: schema.open, onOpenChange: schema.onOpenChange, + onSuccess: handleSuccess, modalSize: schema.modalSize, modalCloseButton: schema.modalCloseButton, }} diff --git a/ui/packages/plugin-form/src/__tests__/containerSaveToastNamesRecord-27453.test.tsx b/ui/packages/plugin-form/src/__tests__/containerSaveToastNamesRecord-27453.test.tsx new file mode 100644 index 00000000000..14ff828a834 --- /dev/null +++ b/ui/packages/plugin-form/src/__tests__/containerSaveToastNamesRecord-27453.test.tsx @@ -0,0 +1,190 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The `object-form` block's drawer and modal containers give a save the + * record-named toast the console's record dialogs give + * (`ux-conventions.write-feedback`): `Task "Fix login" updated`, through the + * one shared builder (`recordSaveMessage` in `@object-ui/i18n`), and before + * the author's `onSuccess` runs — so a React page whose `onSuccess` only + * closes the overlay and reloads its list still tells the user what it wrote. + * + * A host that mounts `DrawerForm` / `ModalForm` itself (the console's record + * dialog, an action modal) owns its own feedback and is not toasted a second + * time; a `submitHandler` owns its own persistence and feedback. + * + * Direction, written before the run: with the container's toast removed, + * every "toasts" row RED (no call); the "not toasted" rows GREEN on both sides. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, waitFor, fireEvent, act, cleanup } from '@testing-library/react'; +import React from 'react'; + +const { toastSuccess } = vi.hoisted(() => ({ toastSuccess: vi.fn() })); +vi.mock('@object-ui/components', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, toast: { ...actual.toast, success: toastSuccess, error: vi.fn() } }; +}); + +import { registerAllFields } from '@object-ui/fields'; +import { ObjectFormContainer } from '../ObjectFormContainer'; +import { DrawerForm } from '../DrawerForm'; + +registerAllFields(); + +const TASK = { + name: 'showcase_task', + label: 'Task', + nameField: 'title', + fields: { + title: { type: 'text', label: 'Title' }, + priority: { type: 'text', label: 'Priority' }, + }, +}; + +const STORED = { id: 't1', title: 'Fix login', priority: 'low' }; + +function makeDS(objectDef: Record = TASK) { + return { + getObjectSchema: vi.fn().mockResolvedValue(objectDef), + findOne: vi.fn().mockResolvedValue({ ...STORED }), + find: vi.fn().mockResolvedValue({ data: [] }), + create: vi.fn(async (_o: string, d: Record) => ({ id: 'n1', ...d })), + update: vi.fn(async (_o: string, _id: string, d: Record) => ({ ...STORED, ...d })), + } as any; +} + +const input = (name: string) => + waitFor(() => { + const el = document.body.querySelector(`input[name="${name}"]`) as HTMLInputElement | null; + if (!el) throw new Error(`${name} not ready`); + return el; + }); + +async function fillAndSubmit(values: Record) { + for (const [name, value] of Object.entries(values)) { + const el = await input(name); + await act(async () => { + fireEvent.change(el, { target: { value } }); + }); + } + await act(async () => { + fireEvent.submit(document.body.querySelector('form') as HTMLFormElement); + }); +} + +const editSchema = (formType: 'drawer' | 'modal', extra: Record = {}) => + ({ + type: 'object-form', + objectName: 'showcase_task', + mode: 'edit', + recordId: 't1', + formType, + open: true, + ...extra, + }) as any; + +const createSchema = (formType: 'drawer' | 'modal', extra: Record = {}) => + ({ type: 'object-form', objectName: 'showcase_task', mode: 'create', formType, open: true, ...extra }) as any; + +beforeEach(() => toastSuccess.mockClear()); +afterEach(cleanup); + +describe.each(['drawer', 'modal'] as const)('`object-form` `formType: %s` — the save toast names the record', (formType) => { + it('edit: toasts `Task "TITLE" updated`, then runs the author’s onSuccess with the record', async () => { + const order: string[] = []; + toastSuccess.mockImplementation(() => order.push('toast')); + const onSuccess = vi.fn(() => { order.push('onSuccess'); }); + const ds = makeDS(); + render(); + await waitFor(async () => expect((await input('priority')).value).toBe('low')); + await fillAndSubmit({ priority: 'high' }); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + expect(ds.update).toHaveBeenCalledTimes(1); + expect(toastSuccess).toHaveBeenCalledTimes(1); + expect(toastSuccess).toHaveBeenCalledWith('Task "Fix login" updated'); + expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({ id: 't1', priority: 'high' })); + expect(order).toEqual(['toast', 'onSuccess']); + }); + + it('create: toasts `Task "TITLE" created`', async () => { + const onSuccess = vi.fn(); + const ds = makeDS(); + render(); + await fillAndSubmit({ title: 'Write release notes' }); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + expect(ds.create).toHaveBeenCalledTimes(1); + expect(toastSuccess).toHaveBeenCalledTimes(1); + expect(toastSuccess).toHaveBeenCalledWith('Task "Write release notes" created'); + }); + + it('a metadata-only block (no onSuccess) is toasted too', async () => { + const ds = makeDS(); + render(); + await fillAndSubmit({ title: 'Write release notes' }); + + await waitFor(() => expect(toastSuccess).toHaveBeenCalledWith('Task "Write release notes" created')); + expect(toastSuccess).toHaveBeenCalledTimes(1); + }); + + it('a record with nothing nameable keeps the object-label toast', async () => { + const onSuccess = vi.fn(); + // No name field, and no name-ish key on the record: the resolver's floor is `Record #`. + const ds = makeDS({ name: 'showcase_task', label: 'Task', fields: { priority: { type: 'text', label: 'Priority' } } }); + render(); + await fillAndSubmit({ priority: 'high' }); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + expect(toastSuccess).toHaveBeenCalledWith('Task created successfully'); + }); + + it('a declared `successMessage` is the toast', async () => { + const onSuccess = vi.fn(); + const ds = makeDS(); + render(); + await fillAndSubmit({ title: 'Write release notes' }); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + expect(toastSuccess).toHaveBeenCalledTimes(1); + expect(toastSuccess).toHaveBeenCalledWith('Queued!'); + }); + + it('a `submitHandler` owns persistence and feedback: not toasted', async () => { + const onSuccess = vi.fn(); + const submitHandler = vi.fn(async (values: Record) => ({ id: 'h1', ...values })); + const ds = makeDS(); + render(); + await fillAndSubmit({ title: 'Write release notes' }); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + expect(submitHandler).toHaveBeenCalledTimes(1); + expect(toastSuccess).not.toHaveBeenCalled(); + }); +}); + +describe('a host that mounts `DrawerForm` itself owns its feedback', () => { + it('is not toasted by the form', async () => { + const onSuccess = vi.fn(); + const ds = makeDS(); + render( + , + ); + await waitFor(async () => expect((await input('priority')).value).toBe('low')); + await fillAndSubmit({ priority: 'high' }); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + expect(ds.update).toHaveBeenCalledTimes(1); + expect(toastSuccess).not.toHaveBeenCalled(); + }); +}); From 078f07674670eb1abcfb5e2faccfb910d7f1dbbe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:22:33 +0000 Subject: [PATCH 2/4] test(ui): pin the nameless-record row on a field the resolver cannot derive a name from Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qiby8nyBCmFZV9NNymGG --- .../__tests__/containerSaveToastNamesRecord-27453.test.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ui/packages/plugin-form/src/__tests__/containerSaveToastNamesRecord-27453.test.tsx b/ui/packages/plugin-form/src/__tests__/containerSaveToastNamesRecord-27453.test.tsx index 14ff828a834..8467759d0a4 100644 --- a/ui/packages/plugin-form/src/__tests__/containerSaveToastNamesRecord-27453.test.tsx +++ b/ui/packages/plugin-form/src/__tests__/containerSaveToastNamesRecord-27453.test.tsx @@ -137,10 +137,11 @@ describe.each(['drawer', 'modal'] as const)('`object-form` `formType: %s` — th it('a record with nothing nameable keeps the object-label toast', async () => { const onSuccess = vi.fn(); - // No name field, and no name-ish key on the record: the resolver's floor is `Record #`. - const ds = makeDS({ name: 'showcase_task', label: 'Task', fields: { priority: { type: 'text', label: 'Priority' } } }); + // No text field to derive a name from, and no name-ish key on the record: + // the resolver's floor is `Record #`. + const ds = makeDS({ name: 'showcase_task', label: 'Task', fields: { estimate: { type: 'number', label: 'Estimate' } } }); render(); - await fillAndSubmit({ priority: 'high' }); + await fillAndSubmit({ estimate: '3' }); await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); expect(toastSuccess).toHaveBeenCalledWith('Task created successfully'); From 8a84e671ce4bf7f3246c039b9069fc96c9a9da47 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:35:18 +0000 Subject: [PATCH 3/4] chore: add changeset for the drawer and modal save toast Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qiby8nyBCmFZV9NNymGG --- .changeset/27453-container-save-toast.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/27453-container-save-toast.md diff --git a/.changeset/27453-container-save-toast.md b/.changeset/27453-container-save-toast.md new file mode 100644 index 00000000000..5dc6b2a1e8b --- /dev/null +++ b/.changeset/27453-container-save-toast.md @@ -0,0 +1,9 @@ +--- +'@object-ui/i18n': patch +'@object-ui/plugin-form': patch +--- + +Clause-②: yes (widening: @object-ui/i18n gains the public export recordSaveMessage; no accept set moves) +Metadata-protocol: none + +A record saved from an `object-form` block's drawer or modal (`formType: 'drawer' | 'modal'`, including `` 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. From feec80395500f45d756c0785fd3f8afaac10818d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:46:01 +0000 Subject: [PATCH 4/4] fix(ui): keep the console dialog's pinned record-label fallback spelling Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qiby8nyBCmFZV9NNymGG --- ui/packages/app-shell/src/console/AppContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/packages/app-shell/src/console/AppContent.tsx b/ui/packages/app-shell/src/console/AppContent.tsx index 66d352b3b7b..b619aa0f917 100644 --- a/ui/packages/app-shell/src/console/AppContent.tsx +++ b/ui/packages/app-shell/src/console/AppContent.tsx @@ -884,7 +884,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = // (`recordSaveMessage` in `@object-ui/i18n`), read through the object being // saved. const recordSaveMessage = useCallback((saved: any): string => buildRecordSaveMessage(t, { - label: formObjectDef ? objectLabel(formObjectDef as any) : t('common.record'), + label: formObjectDef ? objectLabel(formObjectDef as any) : t('common.record', { defaultValue: 'Record' }), objectDef: formObjectDef, saved, mode: editingRecord ? 'edit' : 'create',