From 120c7ea42842cecc3b5dd4113d1b670eac57a5b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 05:30:33 +0000 Subject: [PATCH 1/4] test(app-shell): pin the full-page record form save toast naming the record Red-first pin for create and update on the /new and /edit routes. Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- ...rmPage.saveToastNamesRecord-26461.test.tsx | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 ui/packages/app-shell/src/views/RecordFormPage.saveToastNamesRecord-26461.test.tsx diff --git a/ui/packages/app-shell/src/views/RecordFormPage.saveToastNamesRecord-26461.test.tsx b/ui/packages/app-shell/src/views/RecordFormPage.saveToastNamesRecord-26461.test.tsx new file mode 100644 index 00000000000..8536a37b664 --- /dev/null +++ b/ui/packages/app-shell/src/views/RecordFormPage.saveToastNamesRecord-26461.test.tsx @@ -0,0 +1,155 @@ +/** + * 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. + */ + +/** + * objectstack-ai/objectstack#26461 — the full-page record form (`/new` and + * `/edit`) names the saved record in its success toast, the same way the + * dialog paths do since objectstack-ai/objectstack#26172 + * (`ux-conventions.write-feedback`): `Task "Quarterly review" created`, by its + * ADR-0079 display name. A record with nothing nameable (the resolver's + * `Record #` floor, or no saved record at all) keeps the object-label + * toast (`Task created successfully`). + * + * The form is a stand-in whose button calls `onSuccess` with the saved record, + * as `ObjectForm` does after its write (`schema.onSuccess(result)`); the toast + * is read off sonner's `toast.success`. + * + * Direction, written before the run: with the page's toast still the object + * label alone, every "names the record" cell RED (`Task created + * successfully`); every "no name" cell GREEN on both sides. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { RecordFormPage } from './RecordFormPage'; + +const h = React.createElement; + +const { toastSuccess, saveState } = vi.hoisted(() => ({ + toastSuccess: vi.fn(), + saveState: { saved: undefined as Record | undefined }, +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: toastSuccess, + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }), +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ + user: { id: 'u1', name: 'Ada', email: 'ada@example.com', role: 'admin' }, + getAuthConfig: async () => ({ features: {} }), + activeOrganization: null, + }), +})); + +// Stand-in for the real form: a button that calls `onSuccess` with the saved +// record, as `ObjectForm` hands its write's result to `schema.onSuccess`. +vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectForm: ({ schema }: { schema: { onSuccess?: (saved?: unknown) => void } }) => + h( + 'button', + { + type: 'button', + 'data-testid': 'fire-success', + onClick: () => schema.onSuccess?.(saveState.saved), + }, + 'ok', + ), +})); + +/** Declares its name field: a saved task is named by `subject`. */ +const TASK = { + name: 'showcase_task', + label: 'Task', + nameField: 'subject', + fields: { subject: { type: 'text', label: 'Subject' }, done: { type: 'boolean', label: 'Done' } }, +}; +const METADATA = { objects: [TASK], loading: false }; +vi.mock('../providers/MetadataProvider', () => ({ + useMetadata: () => METADATA, +})); +vi.mock('../providers/AdapterProvider', () => ({ + useAdapter: () => null, +})); + +beforeEach(() => { + toastSuccess.mockClear(); + saveState.saved = undefined; +}); +afterEach(cleanup); + +/** Mount the page in `mode`, save `saved` through the form, and return the toast it raised. */ +async function saveOn( + mode: 'create' | 'edit', + saved: Record | undefined, + language = 'en', +): Promise { + saveState.saved = saved; + const path = + mode === 'create' ? '/apps/crm/showcase_task/new' : '/apps/crm/showcase_task/record/t1/edit'; + const route = + mode === 'create' + ? '/apps/:appName/:objectName/new' + : '/apps/:appName/:objectName/record/:recordId/edit'; + render( + h(I18nProvider, { + config: { defaultLanguage: language, detectBrowserLanguage: false }, + children: h( + MemoryRouter, + { initialEntries: [path] }, + h(Routes, null, h(Route, { path: route, element: h(RecordFormPage, { mode }) })), + ), + }), + ); + fireEvent.click(screen.getByTestId('fire-success')); + await waitFor(() => expect(toastSuccess).toHaveBeenCalledTimes(1)); + return String(toastSuccess.mock.calls[0][0]); +} + +describe('RecordFormPage — the save toast names the saved record (objectstack-ai/objectstack#26461)', () => { + it('create: names the record by its declared name field', async () => { + expect(await saveOn('create', { id: 't1', subject: 'Quarterly review' })).toBe( + 'Task "Quarterly review" created', + ); + }); + + it('update: names the record by its declared name field', async () => { + expect(await saveOn('edit', { id: 't1', subject: 'Quarterly review' })).toBe( + 'Task "Quarterly review" updated', + ); + }); + + it('create, no name (the `Record #` floor): the object-label toast', async () => { + expect(await saveOn('create', { id: 't2', done: false })).toBe('Task created successfully'); + }); + + it('update, no name (the `Record #` floor): the object-label toast', async () => { + expect(await saveOn('edit', { id: 't1', done: true })).toBe('Task updated successfully'); + }); + + it('update, no saved record handed back: the object-label toast', async () => { + expect(await saveOn('edit', undefined)).toBe('Task updated successfully'); + }); + + it('zh: the pack string names the record', async () => { + expect(await saveOn('edit', { id: 't1', subject: '季度复盘' }, 'zh')).toBe('Task“季度复盘”已更新'); + }); +}); From d581cb85b51b8427565209ce3dc01eff474dfb7d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 05:31:04 +0000 Subject: [PATCH 2/4] fix(app-shell): the full-page record form save toast names the saved record RecordFormPage (/new and /edit) now toasts form.createdNamed / form.updatedNamed with the record's ADR-0079 display name, the same resolver, floor and keys the dialog paths in AppContent use; a record with nothing nameable keeps the object-label toast. Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- .../app-shell/src/views/RecordFormPage.tsx | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/ui/packages/app-shell/src/views/RecordFormPage.tsx b/ui/packages/app-shell/src/views/RecordFormPage.tsx index b10a9e6a98b..4442d066e0b 100644 --- a/ui/packages/app-shell/src/views/RecordFormPage.tsx +++ b/ui/packages/app-shell/src/views/RecordFormPage.tsx @@ -34,6 +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 { useMetadata } from '../providers/MetadataProvider.js'; import { useAdapter } from '../providers/AdapterProvider.js'; @@ -156,14 +157,35 @@ export function RecordFormPage({ mode }: RecordFormPageProps) { ? t('form.createTitle', { object: label }) : t('form.editTitle', { object: label }); - const handleSuccess = useCallback(() => { - toast.success( - mode === 'create' - ? t('form.createSuccess', { object: label }) - : t('form.updateSuccess', { 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. + const handleSuccess = useCallback((saved?: any) => { + 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') { + toast.success(title + ? t('form.createdNamed', { object: label, title }) + : t('form.createSuccess', { object: label })); + } else { + toast.success(title + ? t('form.updatedNamed', { object: label, title }) + : t('form.updateSuccess', { object: label })); + } goBack(); - }, [mode, t, label, goBack]); + }, [mode, t, label, objectDef, goBack]); const handleCancel = useCallback(() => { goBack(); From 27e60479a60fd934fad7d160b597d3d2b12a77bf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 05:31:14 +0000 Subject: [PATCH 3/4] test(app-shell): the record form page's named toast keys are in every pack Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- ui/packages/app-shell/src/views/RecordFormPage.i18n.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/packages/app-shell/src/views/RecordFormPage.i18n.test.tsx b/ui/packages/app-shell/src/views/RecordFormPage.i18n.test.tsx index e042349a7ff..02051758ea7 100644 --- a/ui/packages/app-shell/src/views/RecordFormPage.i18n.test.tsx +++ b/ui/packages/app-shell/src/views/RecordFormPage.i18n.test.tsx @@ -221,6 +221,10 @@ describe('the keys RecordFormPage passes bare are defined in every pack (#3469)' 'form.editTitle', 'form.createSuccess', 'form.updateSuccess', + // The named save toasts (objectstack-ai/objectstack#26461), the keys the + // dialog paths use since objectstack-ai/objectstack#26172. + 'form.createdNamed', + 'form.updatedNamed', 'form.saveRecord', // Joined this list in #3517. It was the file's one exception — defined in // NO pack, not even `en` — until the backfill; the describe below pins the From 4a0094b95dc15879396e9d8bbc21a79f1eebc050 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 05:31:28 +0000 Subject: [PATCH 4/4] chore(changeset): record form page save toast names the record Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- .changeset/26461-record-form-page-toast.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/26461-record-form-page-toast.md diff --git a/.changeset/26461-record-form-page-toast.md b/.changeset/26461-record-form-page-toast.md new file mode 100644 index 00000000000..54de10ba28a --- /dev/null +++ b/.changeset/26461-record-form-page-toast.md @@ -0,0 +1,10 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): the full-page record form's save toast names the saved record + +Clause-②: no +Metadata-protocol: none + +Saving a record on the full-page form route (`/new` and `/record/ID/edit`, used by objects whose form opens as a page) showed "Task updated successfully". The record-form dialog paths already named the record. The page now does the same: `Task "Quarterly review" created` and `Task "Quarterly review" updated`. It uses the same display-name resolver, the same rule for a record with no name, and the same `form.createdNamed` / `form.updatedNamed` strings as the dialog. A record with nothing nameable (the resolver would only answer `Record #ID`) keeps the object-label toast it showed before. No strings are added or changed.