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
16 changes: 16 additions & 0 deletions .changeset/26172-record-save-toast-names-record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

fix(app-shell): the record create and update toasts name the saved record

Clause-②: no (adds or rewords message keys; no key is removed, so `TranslationKeys` only widens; no exported runtime contract changes)
Metadata-protocol: none

A user entering several records in a row saw "Task created successfully" after each save and could not tell which record was just written. The Console's record-form save toasts now carry the saved record's display name (ADR-0079), for example `Task "Quarterly review" created` and `Task "Quarterly review" updated`. This covers the record-form overlay on a list and the child-record form opened over a parent record's page.

- **`@object-ui/app-shell`:** the name comes from the same display-name resolver the record page title and lookups use, read through the saved object's declared name field. A record with nothing nameable (the resolver would only answer `Record #ID`) keeps the object-label toast it showed before.
- **`@object-ui/i18n`:** two new keys under `form.` in all ten packs, `createdNamed` and `updatedNamed`, each with `{{object}}` (the object label) and `{{title}}` (the record's name). The existing `form.createSuccess` / `form.updateSuccess` strings are unchanged and remain the toast for a record with no name.

Settings saves and flow-completion toasts are unchanged.
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/i18n.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/i18n","sources":31,"sha256":"7e7907af7823796538ad5a70e56fb172dc533ec3988a2318c8aeddf7069af14a","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/i18n","sources":31,"sha256":"83894d7c91938618b320bda4f6664c1b39a0d5d6e7c3238e8e5eac5c51e1ec39","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
44 changes: 31 additions & 13 deletions ui/packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { ModalForm } from '@object-ui/plugin-form';
import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
import { toast } from 'sonner';
import { useActionRunner, useGlobalUndo, useMutationInvalidationBridge, notifyDataChanged, FilterScopeProvider } from '@object-ui/react';
import { getRecordDisplayName } from '@object-ui/core';
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
import type { AppAccessVerdict, ConnectionState } from '@object-ui/data-objectstack';
import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth';
Expand Down Expand Up @@ -807,17 +808,37 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
: currentObjectDef;
const isChildFormTask = !!formObjectParam && formObjectParam !== currentObjectDef?.name;

const handleCrudSuccess = useCallback(() => {
// 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 #<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 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]);

const handleCrudSuccess = useCallback((saved?: any) => {
executeAction({
type: 'crud_success',
params: {
message: editingRecord
? t('form.updateSuccess', { object: label, defaultValue: `${label} updated successfully` })
: t('form.createSuccess', { object: label, defaultValue: `${label} created successfully` }),
},
params: { message: recordSaveMessage(saved) },
});
}, [executeAction, editingRecord, formObjectDef, objectLabel, t]);
}, [executeAction, recordSaveMessage]);

const handleDialogCancel = useCallback(() => {
executeAction({ type: 'dialog_cancel' });
Expand All @@ -837,14 +858,11 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
// invalidation bus (#2269): the dataSource write already emitted a
// MutationEvent that the bridge fans out — no manual notify needed.
if (isChildFormTask) {
const label = formObjectDef ? objectLabel(formObjectDef as any) : t('common.record', { defaultValue: 'Record' });
toast.success(editingRecord
? t('form.updateSuccess', { object: label, defaultValue: `${label} updated successfully` })
: t('form.createSuccess', { object: label, defaultValue: `${label} created successfully` }));
toast.success(recordSaveMessage(saved));
closeRecordForm();
return;
}
handleCrudSuccess();
handleCrudSuccess(saved);
if (editingRecord || !currentObjectDef) return;
const target = resolvePostCreateTarget({
objectName: currentObjectDef.name,
Expand Down Expand Up @@ -878,7 +896,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
return;
}
navigate(target.url, { replace: true });
}, [handleCrudSuccess, isChildFormTask, formObjectDef, editingRecord, currentObjectDef, appSegment, location.pathname, navigate, closeRecordForm, objectLabel, objectPluralLabel, t]);
}, [handleCrudSuccess, recordSaveMessage, isChildFormTask, editingRecord, currentObjectDef, appSegment, location.pathname, navigate, closeRecordForm, objectPluralLabel]);

// Track recent items on route change. The hook matches `appName` against the
// pathname's app segment, so it takes the ROUTE SEGMENT: handed
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
/**
* 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#26172 — the record create and update success
* toasts name the saved record (`ux-conventions.write-feedback`), e.g.
* `Task "Quarterly review" created`, by its ADR-0079 display name. A record
* with nothing nameable keeps the object-label toast (`Task created
* successfully`).
*
* Measured from a real `AppContent` mount inside a real i18next instance (the
* `AppContent.createLandingOriginPlural-11733` harness): the global record-form
* modal is a stand-in whose `onSuccess` is called with the saved record. Two
* paths raise the toast. The main path hands it to the action runner as a
* `crud_success` message, read here off the runner's `execute`; the child-form
* task (a subtable child opened over its parent's record page) toasts it
* directly, read here off sonner's `toast.success`.
*
* Direction, written before the run: with the toast put back to 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 { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { render, cleanup, waitFor } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { toast } from 'sonner';
import { createI18n, I18nProvider } from '@object-ui/i18n';

/** The part of the modal's schema this file reads. */
interface ModalSchemaProbe {
objectName?: string;
onSuccess: (saved: Record<string, unknown>) => Promise<void> | void;
}
const { modalSchemas, execute } = vi.hoisted(() => ({
modalSchemas: [] as ModalSchemaProbe[],
execute: vi.fn(),
}));

/** The global record-form modal: its schema carries the `onSuccess` under test. */
vi.mock('@object-ui/plugin-form', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
ModalForm: ({ schema }: { schema: ModalSchemaProbe }) => {
modalSchemas.push(schema);
return <div data-testid="modal-form" />;
},
}));

vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({
...(await importOriginal<typeof import('@object-ui/plugin-designer')>()),
CreateAppPage: () => <div data-testid="create-app-page" />,
EditAppPage: () => <div data-testid="edit-app-page" />,
DashboardDesignPage: () => <div data-testid="dashboard-design-page" />,
}));

vi.mock('../../layout/ConsoleLayout', () => ({
ConsoleLayout: ({ children }: { children?: React.ReactNode }) => (
<div data-testid="console-layout">{children}</div>
),
}));
vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null }));
vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null }));
vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null }));
vi.mock('../../views/ObjectView', () => ({ ObjectView: () => <div data-testid="object-view" /> }));
vi.mock('../../views/RecordDetailView', () => ({ RecordDetailView: () => <div data-testid="record-page" /> }));

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => ({
user: { id: 'u1', name: 'Ada', email: 'ada@example.com', role: 'user' },
getAuthConfig: async () => ({ features: {} }),
activeOrganization: null,
}),
useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
}));

const dataSourceStub = {
onConnectionStateChange: () => () => {},
getConnectionState: () => 'connected',
};
vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAdapter: () => dataSourceStub,
}));

const OBJECTS = [
// Declares its name field: a saved task is named by `subject`.
{
name: 'showcase_task',
label: 'Task',
nameField: 'subject',
fields: { subject: { type: 'text', label: 'Subject' }, done: { type: 'boolean', label: 'Done' } },
},
// The parent the child-form task opens over.
{ name: 'showcase_project', label: 'Project', fields: { name: { type: 'text', label: 'Name' } } },
];

const APPS = [{ name: 'crm', label: 'CRM', isDefault: true, navigation: [] }];

// One value for the whole run: the route's effects key on these members.
const METADATA = {
apps: APPS,
objects: OBJECTS,
loading: false,
ensureType: undefined,
error: null,
refresh: async () => {},
};
vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useMetadata: () => METADATA,
}));

const ACTION_RUNNER = { execute, runner: { registerHandler: () => {}, getContext: () => ({}) } };
vi.mock('@object-ui/react', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useActionRunner: () => ACTION_RUNNER,
useGlobalUndo: () => {},
useMutationInvalidationBridge: () => {},
}));

import { AppContent } from '../AppContent';

type Lang = 'en' | 'zh-CN';

/** The catalog as the console loads it: `zh-CN` translates the task's label. */
const BUNDLE: Record<Lang, Record<string, unknown>> = {
en: {},
'zh-CN': { showcase: { objects: { showcase_task: { label: '任务' } } } },
};

const MAIN_CREATE = '/apps/crm/showcase_task?form=new';
const MAIN_EDIT = '/apps/crm/showcase_task?form=t1';
const CHILD_CREATE = '/apps/crm/showcase_project/record/p1?form=new&formObject=showcase_task';
const CHILD_EDIT = '/apps/crm/showcase_project/record/p1?form=t1&formObject=showcase_task';

let toastSuccess: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
modalSchemas.length = 0;
execute.mockClear();
toastSuccess = vi.spyOn(toast, 'success');
});
afterEach(() => {
toastSuccess.mockRestore();
cleanup();
});

/** Mount at `url`, save `saved` through the record-form modal, and wait for its toast. */
async function saveFrom(url: string, saved: Record<string, unknown>, lang: Lang = 'en'): Promise<void> {
const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false });
expect(i18n.language).toBe(lang);
i18n.addResourceBundle(lang, 'translation', BUNDLE[lang], true, true);
render(
<I18nProvider instance={i18n}>
<MemoryRouter initialEntries={[url]}>
<Routes>
<Route path="/apps/:appName/*" element={<AppContent />} />
</Routes>
</MemoryRouter>
</I18nProvider>,
);
await waitFor(() => expect(modalSchemas.length).toBeGreaterThan(0));
const schema = modalSchemas[modalSchemas.length - 1];
expect(schema.objectName).toBe('showcase_task');
// Not inside `act`, as in the harness this file copies: the save settles at once.
await schema.onSuccess(saved);
}

/** The message the main path handed the runner as its `crud_success`. */
function crudSuccessMessage(): string | undefined {
const call = execute.mock.calls.find(([action]) => action?.type === 'crud_success');
return call?.[0]?.params?.message;
}

/** The message the child-form task toasted directly. */
function childToastMessage(): string | undefined {
return toastSuccess.mock.calls[0]?.[0] as string | undefined;
}

describe('AppContent — the record save toast names the saved record (objectstack-ai/objectstack#26172)', () => {
describe('main path (crud_success)', () => {
it('create: names the record by its declared name field', async () => {
await saveFrom(MAIN_CREATE, { id: 't1', subject: 'Quarterly review' });
expect(crudSuccessMessage()).toBe('Task "Quarterly review" created');
});

it('update: names the record by its declared name field', async () => {
await saveFrom(MAIN_EDIT, { id: 't1', subject: 'Quarterly review' });
expect(crudSuccessMessage()).toBe('Task "Quarterly review" updated');
});

it('create, no name: the object-label toast', async () => {
await saveFrom(MAIN_CREATE, { id: 't2', done: false });
expect(crudSuccessMessage()).toBe('Task created successfully');
});

it('update, no name: the object-label toast', async () => {
await saveFrom(MAIN_EDIT, { id: 't1', done: true });
expect(crudSuccessMessage()).toBe('Task updated successfully');
});

it('zh-CN: the pack string, with the translated object label', async () => {
await saveFrom(MAIN_CREATE, { id: 't1', subject: '季度复盘' }, 'zh-CN');
expect(crudSuccessMessage()).toBe('任务“季度复盘”已创建');
});
});

describe('child-form task (direct toast)', () => {
it('create: names the record', async () => {
await saveFrom(CHILD_CREATE, { id: 't1', subject: 'Quarterly review' });
expect(childToastMessage()).toBe('Task "Quarterly review" created');
// The child task keeps the parent page as it is: no crud_success.
expect(crudSuccessMessage()).toBeUndefined();
});

it('update: names the record', async () => {
await saveFrom(CHILD_EDIT, { id: 't1', subject: 'Quarterly review' });
expect(childToastMessage()).toBe('Task "Quarterly review" updated');
});

it('create, no name: the object-label toast', async () => {
await saveFrom(CHILD_CREATE, { id: 't2', done: false });
expect(childToastMessage()).toBe('Task created successfully');
});

it('update, no name: the object-label toast', async () => {
await saveFrom(CHILD_EDIT, { id: 't1', done: true });
expect(childToastMessage()).toBe('Task updated successfully');
});
});
});
11 changes: 8 additions & 3 deletions ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,10 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight
// 94 once objectui#13355 added `connectAgent.apiKey.confirmTitle`, the
// API-key confirm that names the key („{{name}}“) — one interpolated span,
// runtime data.
expect(okSpans, 'correctly paired spans').toBe(94);
// 96 once objectstack-ai/objectstack#26172 added `form.createdNamed` and
// `.updatedNamed`, the record-save toasts that name the record
// („{{title}}“) — one interpolated span each, runtime data.
expect(okSpans, 'correctly paired spans').toBe(96);
});

it('keeps the count identity that replaces the card’s count(„) === count(“)', () => {
Expand Down Expand Up @@ -413,8 +416,10 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight
// `detail.deleteRowConfirmation`, one more matched pair. 93 / 93 / 0 after
// objectui#13330 added `console.objectView.managedUsers.inviteLine`, one
// more matched pair. 94 / 94 / 0 after objectui#13355 added
// `connectAgent.apiKey.confirmTitle`, one more matched pair.
expect({ open, close, rdq }).toEqual({ open: 94, close: 94, rdq: 0 });
// `connectAgent.apiKey.confirmTitle`, one more matched pair. 96 / 96 / 0
// after objectstack-ai/objectstack#26172 added `form.createdNamed` and
// `.updatedNamed`, one matched pair each.
expect({ open, close, rdq }).toEqual({ open: 96, close: 96, rdq: 0 });
// The durable shape: every „ closed by a “, every surplus “ an English
// opener answered by a ”. Survived translating the two English values.
expect(close).toBe(open + rdq);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -383,10 +383,12 @@ describe('objectui#3546 slice seven — the ratchet residue', () => {
}
// PageView uses it twice on the same button (title + aria-label) and
// RelatedList twice in two different affordances — the counts are pinned so a
// silent drop of one of them is visible.
// silent drop of one of them is visible. AppContent's two record-save toast
// sites share one message builder since objectstack-ai/objectstack#26172,
// so its fallback label is written once.
expect(sourceOf(PAGE_VIEW).split("t('common.editInStudio', { defaultValue: 'Edit in studio' })").length - 1).toBe(2);
expect(sourceOf(RELATED_LIST).split("t('detail.add', { defaultValue: 'Add' })").length - 1).toBe(2);
expect(sourceOf(APP_CONTENT).split("t('common.record', { defaultValue: 'Record' })").length - 1).toBe(2);
expect(sourceOf(APP_CONTENT).split("t('common.record', { defaultValue: 'Record' })").length - 1).toBe(1);
});

it('dashboard.loading matches useSafeTranslate positional fallback, not a defaultValue', () => {
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,8 @@ const ar = {
update: "تحديث",
createSuccess: "تم إنشاء {{object}}",
updateSuccess: "تم تحديث {{object}}",
createdNamed: "تم إنشاء {{object}} \"{{title}}\"",
updatedNamed: "تم تحديث {{object}} \"{{title}}\"",
created: "تم الإنشاء",
saved: "تم الحفظ",
savedNamed: "تم حفظ {{title}}",
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@ const de = {
update: "Aktualisieren",
createSuccess: "{{object}} erfolgreich erstellt",
updateSuccess: "{{object}} erfolgreich aktualisiert",
createdNamed: "{{object}} „{{title}}“ erstellt",
updatedNamed: "{{object}} „{{title}}“ aktualisiert",
created: "Erstellt",
saved: "Gespeichert",
savedNamed: "{{title}} gespeichert",
Expand Down
Loading
Loading