Skip to content

Commit 185512c

Browse files
fix(app-shell): the full-page record form save toast names the saved record (#26528)
Fixes #26461 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:整页记录表单(`/new`、`/edit` 路由)保存成功提示改为点名记录标题,如 `Task "Quarterly review" updated`;复用弹窗路径同一解析器、同一无名兜底、同两个文案键,不新增文案。 - 为什么改:弹窗路径(#26202)已按裁决 C 点名记录,整页路由仍只显示「Task updated successfully」,本单补齐这一半。 - 风险与代价(含回滚):只改一个视图的提示文字,无标题记录仍显示原对象名提示;新测试先红后绿,10 个相关测试文件 62/62 通过;回滚即还原本 PR。 - 席位意见: - 你要做的:无需决策;CI 绿后按常规评审落地。 ## What The full-page record form (`RecordFormPage`, the `/new` and `/record/ID/edit` routes used by objects whose form opens as a page) now names the saved record in its success toast. It works the same way as the dialog paths that PR #26202 changed in `ui/packages/app-shell/src/console/AppContent.tsx` (`recordSaveMessage`): - The name is `getRecordDisplayName` from `@object-ui/core` (ADR-0079), read through this page's object, so it follows the object's declared `nameField`. - The floor is the same. The resolver's `Record #ID` answer, or an empty answer for a row with no id, is not a name, so the page falls back to `form.createSuccess` / `form.updateSuccess` (`Task created successfully`). The page also falls back when `onSuccess` receives no record. - The keys are the same: `form.createdNamed` / `form.updatedNamed`. They are already in all ten packs, so no locale file changes and no wording is added. As with every other `t()` in this file, the keys are passed without an inline `defaultValue`. - `ObjectForm` already hands the write's result to `schema.onSuccess(result)` in every layout (simple, tabbed, wizard, split), the same contract `ModalForm` serves to the dialog path. `handleSuccess` now reads that argument. Files: - `ui/packages/app-shell/src/views/RecordFormPage.tsx`: `handleSuccess(saved)` builds the named or fallback toast. - `ui/packages/app-shell/src/views/RecordFormPage.saveToastNamesRecord-26461.test.tsx` (new): six cells. Create and update with a name, create and update at the `Record #ID` floor, update with no record handed back, and one zh cell. - `ui/packages/app-shell/src/views/RecordFormPage.i18n.test.tsx`: the two named keys join the page's bare-keys list, which is pinned present in all ten packs. - `.changeset/26461-record-form-page-toast.md`: patch, `@object-ui/app-shell`. ## Tests All runs are at head `4a0094b9`, under `scripts/pm/os-verify-lock.sh`. - Red first: the test commit's page (`RecordFormPage.tsx` restored tree-only to the base `00a4593a`, with `getRecordDisplayName` count 0) gave **3 failed / 3 passed**. The three "names the record" cells failed (`expected 'Task created successfully' to be 'Task "Quarterly review" created'`, the update cell the same, and zh `expected 'Task更新成功' ...`). The three no-name cells passed. This matches the direction written in the test header before the run. Restore: `git checkout HEAD -- RecordFormPage.tsx`, then the blob equals HEAD (`4442d066`) and `git status` is clean. - Green: the ten app-shell test files that import `RecordFormPage` (the nine that exist plus the new one) gave **Test Files 10 passed (10), Tests 62 passed (62)**. - `pnpm --filter @object-ui/app-shell type-check`: exit 0. Its `tsconfig.test.json` includes `src/**/*.test.tsx`, so the new file is compiled. - The dependency closure was built first: `pnpm --filter '@object-ui/app-shell^...' build`, VERDICT command-exit 0. Narrowed on purpose: the whole app-shell suite is left to CI `Build & E2E UI` / `Test Core`. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 34 families. All 34 were run locally after the last commit, and `--ran` accounted for 34 of 34. - 33 exited 0, including `node scripts/check-sdui-manifest.mjs` (present and intact, 106 components) and its `--self-test`, and `pnpm --dir ui exec vitest run scripts/` (178 files passed, 2 skipped; 5097 tests passed). - `pnpm check:type-check-debt` exited **3 (PREREQUISITE NOT MET)**. This is NOT MEASURED, not a pass and not a finding. It is a repo-wide `--re-measure` that needs a full package build behind the shared lock. CI's `Lint & Repo Gates` run is the reading of record. - No registry component contract changed, so `sdui.manifest.json` was not regenerated. ## Acceptance notes - On an edit, the name is read off what the save returned. This is the same caveat PR #26202 recorded for the dialog path: the toast can name the record only if the write's response carries the name field. A live server was not booted for this PR. - `recordSaveMessage` in `AppContent.tsx` and `handleSuccess` here now repeat the same resolver-and-floor steps (about six lines). Extracting one shared builder would change `AppContent.tsx`, which is outside this card's file surface, so it is not done here. --- _Generated by [Claude Code](https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 28804e8 commit 185512c

4 files changed

Lines changed: 198 additions & 7 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): the full-page record form's save toast names the saved record
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,10 @@ describe('the keys RecordFormPage passes bare are defined in every pack (#3469)'
221221
'form.editTitle',
222222
'form.createSuccess',
223223
'form.updateSuccess',
224+
// The named save toasts (objectstack-ai/objectstack#26461), the keys the
225+
// dialog paths use since objectstack-ai/objectstack#26172.
226+
'form.createdNamed',
227+
'form.updatedNamed',
224228
'form.saveRecord',
225229
// Joined this list in #3517. It was the file's one exception — defined in
226230
// NO pack, not even `en` — until the backfill; the describe below pins the
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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+
* objectstack-ai/objectstack#26461 — the full-page record form (`/new` and
11+
* `/edit`) names the saved record in its success toast, the same way the
12+
* dialog paths do since objectstack-ai/objectstack#26172
13+
* (`ux-conventions.write-feedback`): `Task "Quarterly review" created`, by its
14+
* ADR-0079 display name. A record with nothing nameable (the resolver's
15+
* `Record #<id>` floor, or no saved record at all) keeps the object-label
16+
* toast (`Task created successfully`).
17+
*
18+
* The form is a stand-in whose button calls `onSuccess` with the saved record,
19+
* as `ObjectForm` does after its write (`schema.onSuccess(result)`); the toast
20+
* is read off sonner's `toast.success`.
21+
*
22+
* Direction, written before the run: with the page's toast still the object
23+
* label alone, every "names the record" cell RED (`Task created
24+
* successfully`); every "no name" cell GREEN on both sides.
25+
*/
26+
27+
import '@testing-library/jest-dom/vitest';
28+
import * as React from 'react';
29+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
30+
import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react';
31+
import { MemoryRouter, Routes, Route } from 'react-router-dom';
32+
import { I18nProvider } from '@object-ui/i18n';
33+
import { RecordFormPage } from './RecordFormPage';
34+
35+
const h = React.createElement;
36+
37+
const { toastSuccess, saveState } = vi.hoisted(() => ({
38+
toastSuccess: vi.fn(),
39+
saveState: { saved: undefined as Record<string, unknown> | undefined },
40+
}));
41+
42+
vi.mock('sonner', () => ({
43+
toast: Object.assign(vi.fn(), {
44+
success: toastSuccess,
45+
error: vi.fn(),
46+
info: vi.fn(),
47+
warning: vi.fn(),
48+
loading: vi.fn(),
49+
dismiss: vi.fn(),
50+
}),
51+
}));
52+
53+
vi.mock('@object-ui/auth', async (importOriginal) => ({
54+
...(await importOriginal<Record<string, unknown>>()),
55+
useAuth: () => ({
56+
user: { id: 'u1', name: 'Ada', email: 'ada@example.com', role: 'admin' },
57+
getAuthConfig: async () => ({ features: {} }),
58+
activeOrganization: null,
59+
}),
60+
}));
61+
62+
// Stand-in for the real form: a button that calls `onSuccess` with the saved
63+
// record, as `ObjectForm` hands its write's result to `schema.onSuccess`.
64+
vi.mock('@object-ui/plugin-form', async (importOriginal) => ({
65+
...(await importOriginal<Record<string, unknown>>()),
66+
ObjectForm: ({ schema }: { schema: { onSuccess?: (saved?: unknown) => void } }) =>
67+
h(
68+
'button',
69+
{
70+
type: 'button',
71+
'data-testid': 'fire-success',
72+
onClick: () => schema.onSuccess?.(saveState.saved),
73+
},
74+
'ok',
75+
),
76+
}));
77+
78+
/** Declares its name field: a saved task is named by `subject`. */
79+
const TASK = {
80+
name: 'showcase_task',
81+
label: 'Task',
82+
nameField: 'subject',
83+
fields: { subject: { type: 'text', label: 'Subject' }, done: { type: 'boolean', label: 'Done' } },
84+
};
85+
const METADATA = { objects: [TASK], loading: false };
86+
vi.mock('../providers/MetadataProvider', () => ({
87+
useMetadata: () => METADATA,
88+
}));
89+
vi.mock('../providers/AdapterProvider', () => ({
90+
useAdapter: () => null,
91+
}));
92+
93+
beforeEach(() => {
94+
toastSuccess.mockClear();
95+
saveState.saved = undefined;
96+
});
97+
afterEach(cleanup);
98+
99+
/** Mount the page in `mode`, save `saved` through the form, and return the toast it raised. */
100+
async function saveOn(
101+
mode: 'create' | 'edit',
102+
saved: Record<string, unknown> | undefined,
103+
language = 'en',
104+
): Promise<string> {
105+
saveState.saved = saved;
106+
const path =
107+
mode === 'create' ? '/apps/crm/showcase_task/new' : '/apps/crm/showcase_task/record/t1/edit';
108+
const route =
109+
mode === 'create'
110+
? '/apps/:appName/:objectName/new'
111+
: '/apps/:appName/:objectName/record/:recordId/edit';
112+
render(
113+
h(I18nProvider, {
114+
config: { defaultLanguage: language, detectBrowserLanguage: false },
115+
children: h(
116+
MemoryRouter,
117+
{ initialEntries: [path] },
118+
h(Routes, null, h(Route, { path: route, element: h(RecordFormPage, { mode }) })),
119+
),
120+
}),
121+
);
122+
fireEvent.click(screen.getByTestId('fire-success'));
123+
await waitFor(() => expect(toastSuccess).toHaveBeenCalledTimes(1));
124+
return String(toastSuccess.mock.calls[0][0]);
125+
}
126+
127+
describe('RecordFormPage — the save toast names the saved record (objectstack-ai/objectstack#26461)', () => {
128+
it('create: names the record by its declared name field', async () => {
129+
expect(await saveOn('create', { id: 't1', subject: 'Quarterly review' })).toBe(
130+
'Task "Quarterly review" created',
131+
);
132+
});
133+
134+
it('update: names the record by its declared name field', async () => {
135+
expect(await saveOn('edit', { id: 't1', subject: 'Quarterly review' })).toBe(
136+
'Task "Quarterly review" updated',
137+
);
138+
});
139+
140+
it('create, no name (the `Record #<id>` floor): the object-label toast', async () => {
141+
expect(await saveOn('create', { id: 't2', done: false })).toBe('Task created successfully');
142+
});
143+
144+
it('update, no name (the `Record #<id>` floor): the object-label toast', async () => {
145+
expect(await saveOn('edit', { id: 't1', done: true })).toBe('Task updated successfully');
146+
});
147+
148+
it('update, no saved record handed back: the object-label toast', async () => {
149+
expect(await saveOn('edit', undefined)).toBe('Task updated successfully');
150+
});
151+
152+
it('zh: the pack string names the record', async () => {
153+
expect(await saveOn('edit', { id: 't1', subject: '季度复盘' }, 'zh')).toBe('Task“季度复盘”已更新');
154+
});
155+
});

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

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +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';
3738
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
3839
import { useMetadata } from '../providers/MetadataProvider.js';
3940
import { useAdapter } from '../providers/AdapterProvider.js';
@@ -156,14 +157,35 @@ export function RecordFormPage({ mode }: RecordFormPageProps) {
156157
? t('form.createTitle', { object: label })
157158
: t('form.editTitle', { object: label });
158159

159-
const handleSuccess = useCallback(() => {
160-
toast.success(
161-
mode === 'create'
162-
? t('form.createSuccess', { object: label })
163-
: t('form.updateSuccess', { object: label }),
164-
);
160+
// objectstack-ai/objectstack#26461 — the save toast names the record it
161+
// wrote (`ux-conventions.write-feedback`), as the dialog paths in
162+
// `console/AppContent.tsx` (`recordSaveMessage`) have since
163+
// objectstack-ai/objectstack#26172: `Task "Quarterly review" created`. Same
164+
// resolver, same floor, same keys. The name is the saved record's display
165+
// name from the ONE ADR-0079 resolver, read through this page's object;
166+
// `ObjectForm` hands the write's result to `onSuccess`. The resolver's floor
167+
// (`Record #<id>`, or the empty fallback for an id-less row) is not a name,
168+
// so a record with nothing nameable keeps the object-label toast. The two
169+
// named keys are in all ten packs, so they pass bare like every key above.
170+
const handleSuccess = useCallback((saved?: any) => {
171+
const resolved = saved && typeof saved === 'object'
172+
? getRecordDisplayName(objectDef, saved, { fallback: '' })
173+
: '';
174+
const id = saved?.id ?? saved?._id;
175+
const title = resolved && !(id !== null && id !== undefined && resolved === `Record #${id}`)
176+
? resolved
177+
: undefined;
178+
if (mode === 'create') {
179+
toast.success(title
180+
? t('form.createdNamed', { object: label, title })
181+
: t('form.createSuccess', { object: label }));
182+
} else {
183+
toast.success(title
184+
? t('form.updatedNamed', { object: label, title })
185+
: t('form.updateSuccess', { object: label }));
186+
}
165187
goBack();
166-
}, [mode, t, label, goBack]);
188+
}, [mode, t, label, objectDef, goBack]);
167189

168190
const handleCancel = useCallback(() => {
169191
goBack();

0 commit comments

Comments
 (0)