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
11 changes: 11 additions & 0 deletions .changeset/27999-save-with-warnings-one-toast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/app-shell': patch
'@object-ui/console': patch
---

fix(console): a record save whose answer carries warnings shows one toast, the record-named save message carrying the warning lines

Clause-②: no
Metadata-protocol: none

A save from the record form (the overlay dialog, its child-form task, or the full-page form) that passed a `severity: 'warning'` validation rule showed two toasts: the write-warning channel's "Saved — with warnings" with the rule messages, then the form's own `OBJECT "TITLE" updated`. It now shows one: the record-named save message, on the warning variant, with each distinct rule message on its own line. While a record form is open, the write-warning channel hands the answer warnings of the write that form makes (that record's update, or a create of that object) to the form instead of toasting them; the stripped-fields notice keeps its own toast, and any other write keeps the "Saved — with warnings" toast.
34 changes: 29 additions & 5 deletions ui/packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type { AppAccessVerdict, ConnectionState } from '@object-ui/data-objectst
import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth';
import { useMetadata } from '../providers/MetadataProvider.js';
import { useAdapter } from '../providers/AdapterProvider.js';
import { claimAnswerWarnings, toastRecordSave, type AnswerWarningClaim } from '../providers/writeWarningToast.js';
import { usePreviewDrafts } from '../preview/PreviewModeContext.js';
import { PreviewDraftEmptyState } from '../preview/PreviewDraftEmptyState.js';
import {
Expand Down Expand Up @@ -764,7 +765,9 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
runner.registerHandler('crud_success', async (action: any) => {
closeRecordForm();
setRefreshKey(k => k + 1);
toast.success(action.params?.message ?? 'Record saved successfully');
// `warnings`: the save's own advisory warning lines, which this one
// toast carries (objectstack-ai/objectstack#27999).
toastRecordSave(toast, action.params?.message ?? 'Record saved successfully', action.params?.warnings);
return { success: true, reload: true };
});

Expand Down Expand Up @@ -901,10 +904,30 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
: t('form.createSuccess', { object: label, defaultValue: `${label} created successfully` });
}, [editingRecord, formObjectDef, objectLabel, t]);

const handleCrudSuccess = useCallback((saved?: any) => {
// objectstack-ai/objectstack#27999 — the open record form reports its own
// write's advisory warnings: while the overlay is open, the write-warning
// channel parks the answer warnings of the write it names (this record's
// update, or a create of this object) on the claim instead of toasting them,
// and the save toast carries them. Keyed on the URL's primitives, not on the
// memoised `editingRecord` / `formObjectDef` (ui/AGENTS.md #10).
const answerWarningClaim = useRef<AnswerWarningClaim | null>(null);
const formObjectName: string | undefined = formObjectDef?.name;
useEffect(() => {
if (!dataSource || !recordFormParam || !formObjectName) return;
const claim = claimAnswerWarnings(dataSource, recordFormParam === 'new'
? { operation: 'create', resource: formObjectName }
: { operation: 'update', resource: formObjectName, id: recordFormParam });
answerWarningClaim.current = claim;
return () => {
if (answerWarningClaim.current === claim) answerWarningClaim.current = null;
claim.release();
};
}, [dataSource, recordFormParam, formObjectName]);

const handleCrudSuccess = useCallback((saved?: any, warnings?: string[]) => {
executeAction({
type: 'crud_success',
params: { message: recordSaveMessage(saved) },
params: { message: recordSaveMessage(saved), warnings },
});
}, [executeAction, recordSaveMessage]);

Expand All @@ -925,12 +948,13 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
// the child's open related lists refetch on their own via the
// invalidation bus (#2269): the dataSource write already emitted a
// MutationEvent that the bridge fans out — no manual notify needed.
const warnings = answerWarningClaim.current?.take(saved) ?? [];
if (isChildFormTask) {
toast.success(recordSaveMessage(saved));
toastRecordSave(toast, recordSaveMessage(saved), warnings);
closeRecordForm();
return;
}
handleCrudSuccess(saved);
handleCrudSuccess(saved, warnings);
if (editingRecord || !currentObjectDef) return;
const target = resolvePostCreateTarget({
objectName: currentObjectDef.name,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
/**
* 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#27999 — an overlay record save whose answer
* carries advisory warnings (a `severity: 'warning'` validation rule) shows ONE
* toast: the record-named save message (objectstack-ai/objectstack#26172),
* carrying the warning lines. The write-warning channel's "Saved — with
* warnings" toast is not raised for that write; its stripped-fields notice
* still is, and a write the overlay did not make keeps the channel toast.
*
* Measured from a real `AppContent` mount (the
* `AppContent.recordSaveToastNamesRecord-26172` harness): the global record-form
* modal is a stand-in, and the save does what the real chain does, in its
* order: the adapter emits the write-warning event INSIDE the write (here the
* channel's `emitWriteWarning`, handed the adapter as `AdapterProvider` hands
* it, and not awaited), then the form hands the write's result to `onSuccess`.
* The runner runs the handler `AppContent` registered, so the main path's
* toast is the real `crud_success` one; the child-form task toasts directly.
* The channel's sink is the same sonner `toast`, so every toast is counted.
*
* Direction, written before the run: with the channel ignoring the overlay's
* claim, every "one toast" cell RED (a second "Saved — with warnings" toast,
* and a success toast carrying no warning line); the CONTROL cells GREEN on
* both sides.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } 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';
import type { WriteWarningEvent } from '@object-ui/data-objectstack';
import { emitWriteWarning } from '../../providers/writeWarningToast';

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

/** 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 }),
}));

// The adapter the overlay claims on, and the one the channel is handed as
// `AdapterProvider` hands it: the same object.
const dataSourceStub = {
onConnectionStateChange: () => () => {},
getConnectionState: () => 'connected',
getObjectSchema: async () => ({ fields: {} as Record<string, { label?: string }> }),
};
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,
}));

// The runner runs what `AppContent` registered, so the `crud_success` toast is
// the real handler's.
const ACTION_RUNNER = {
execute,
runner: {
registerHandler: (type: string, fn: (action: unknown) => Promise<unknown>) => { handlers.set(type, fn); },
getContext: () => ({}),
},
};
vi.mock('@object-ui/react', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useActionRunner: () => ACTION_RUNNER,
useGlobalUndo: () => {},
useMutationInvalidationBridge: () => {},
}));

// objectstack#27138 — an edit dialog reads the approvals state of the record it
// edits (`useRecordApprovals`). No approval runs in this file: the approvals
// routes answer empty from one double, installed for the whole file so no read
// can reach a socket after a test body returns, and handed back in `afterAll`.
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})));
afterAll(() => vi.unstubAllGlobals());

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

/** The channel's translator: the English default, interpolated. */
const t = (_key: string, opts?: Record<string, unknown>) =>
String(opts?.defaultValue ?? '').replace('{{fields}}', String(opts?.fields ?? ''));
const identityLabel = (_o: string, _f: string, fallback: string) => fallback;

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

const ADVISORY = {
field: 'subject',
code: 'validation_rule',
message: 'Reviews are usually scheduled a week ahead',
rule: 'review_lead_time',
severity: 'warning' as const,
};
const SECOND = { ...ADVISORY, field: 'done', rule: 'done_owner', message: 'Done tasks usually name an owner' };
const NAMED = { id: 't1', subject: 'Quarterly review' };

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

/** Mount at `url`, save `saved` with `event` emitted by the write, and settle every toast. */
async function saveFrom(url: string, event: WriteWarningEvent, saved: Record<string, unknown>): Promise<void> {
const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
render(
<I18nProvider instance={i18n}>
<MemoryRouter initialEntries={[url]}>
<Routes>
<Route path="/apps/:appName/*" element={<AppContent />} />
</Routes>
</MemoryRouter>
</I18nProvider>,
);
await waitFor(() => expect(modalSchemas.length).toBeGreaterThan(0));
// The overlay's claim is opened by an effect of the mounted, open dialog.
await waitFor(() => expect(handlers.has('crud_success')).toBe(true));
const schema = modalSchemas[modalSchemas.length - 1];
expect(schema.objectName).toBe('showcase_task');
const channel = emitWriteWarning(event, t, dataSourceStub as never, identityLabel, toast);
await schema.onSuccess(saved);
await channel;
await waitFor(() => expect(toastSuccess.mock.calls.length + toastWarning.mock.calls.length).toBeGreaterThan(0));
}

const update = (over: Partial<WriteWarningEvent> = {}): WriteWarningEvent => ({
operation: 'update',
resource: 'showcase_task',
id: 't1',
droppedFields: [],
warnings: [ADVISORY],
...over,
});

describe('AppContent — an overlay save with warnings shows one record-named toast (objectstack-ai/objectstack#27999)', () => {
it('update (crud_success): one toast, the record-named message carrying the warning line', async () => {
await saveFrom(MAIN_EDIT, update(), NAMED);

expect(toastSuccess).not.toHaveBeenCalled();
expect(toastWarning.mock.calls).toEqual([
['Task "Quarterly review" updated', { description: ADVISORY.message }],
]);
});

it('create (crud_success): one toast, each distinct warning on its own line', async () => {
await saveFrom(
MAIN_CREATE,
{ operation: 'create', resource: 'showcase_task', id: 't1', droppedFields: [], warnings: [ADVISORY, SECOND] },
NAMED,
);

expect(toastSuccess).not.toHaveBeenCalled();
expect(toastWarning.mock.calls).toEqual([
['Task "Quarterly review" created', { description: `${ADVISORY.message}\n${SECOND.message}` }],
]);
});

it('child-form task: the direct toast is the one toast', async () => {
await saveFrom(CHILD_EDIT, update(), NAMED);

expect(toastSuccess).not.toHaveBeenCalled();
expect(toastWarning.mock.calls).toEqual([
['Task "Quarterly review" updated', { description: ADVISORY.message }],
]);
});

it('the stripped-fields notice keeps its own toast beside the save toast', async () => {
await saveFrom(
MAIN_EDIT,
update({ droppedFields: [{ object: 'showcase_task', fields: ['done'], reason: 'readonly' }] }),
NAMED,
);

expect(toastSuccess).not.toHaveBeenCalled();
expect(toastWarning.mock.calls.map((call: unknown[]) => call[0]).sort()).toEqual([
'Saved — but some fields did not take effect',
'Task "Quarterly review" updated',
]);
expect(toastWarning.mock.calls.find((call: unknown[]) => call[0] === 'Task "Quarterly review" updated')?.[1]).toEqual({
description: ADVISORY.message,
});
});

it('CONTROL: a write the overlay did not make keeps the channel toast, and the save toast stays a success', async () => {
await saveFrom(MAIN_EDIT, update({ id: 't9' }), NAMED);

expect(toastSuccess.mock.calls).toEqual([['Task "Quarterly review" updated']]);
expect(toastWarning.mock.calls).toEqual([['Saved — with warnings', { description: ADVISORY.message }]]);
});
});
Loading
Loading