Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/26461-record-form-page-toast.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions ui/packages/app-shell/src/views/RecordFormPage.i18n.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 #<id>` 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<string, unknown> | 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<Record<string, unknown>>()),
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<Record<string, unknown>>()),
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<string, unknown> | undefined,
language = 'en',
): Promise<string> {
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 #<id>` floor): the object-label toast', async () => {
expect(await saveOn('create', { id: 't2', done: false })).toBe('Task created successfully');
});

it('update, no name (the `Record #<id>` 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“季度复盘”已更新');
});
});
36 changes: 29 additions & 7 deletions ui/packages/app-shell/src/views/RecordFormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 #<id>`, 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();
Expand Down
Loading