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
17 changes: 17 additions & 0 deletions .changeset/28252-save-toast-hook-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@object-ui/data-objectstack': patch
'@object-ui/react': patch
'@object-ui/app-shell': patch
'@object-ui/plugin-form': patch
'@object-ui/plugin-grid': patch
---

feat(console): a save's toast shows the notice a hook set on the write, still one toast per write

Clause-②: yes (widening)
Metadata-protocol: none

A create or update answer can carry a `notice`: one message a hook on the write set for the person who made it, such as "Sent to your manager for approval". The Console now shows it. The record form's save toast (the record dialog, its child-form task and the full-page form), the `object-form` drawer and modal, and the grid's inline-edit saves (the per-row save and "Save All") keep their own message as the title and show the notice under it. The toast stays a success when the notice is the only news, and moves to the warning variant, with the notice leading the lines, when the answer also carried warnings. A write no save surface reports itself, such as the record page's inline edit Save bar, gets one toast from the write-warning channel instead: an info toast with the notice as the hook wrote it, or the notice leading the "Saved — with warnings" lines.

- **`@object-ui/data-objectstack`**: `WriteWarningEvent` gains an optional `notice`, read off the single-record create and update answers. The event is now also emitted when the notice is the answer's only news. What `create` and `update` resolve is unchanged.
- **`@object-ui/react`**: new type `RecordSaveFeedback` (`warnings`, optional `notice`). `AnswerWarningClaim.take` returns it, `AnswerWarningsEvent` gains `notice`, and `toastRecordSave` takes it as its third argument. These exports have not been released yet, so no released signature changes.
22 changes: 13 additions & 9 deletions ui/packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { useAssistant } from '../assistant/assistantBus.js';
import { ModalForm } from '@object-ui/plugin-form';
import { Empty, EmptyTitle, EmptyDescription, Button, DataErrorState } from '@object-ui/components';
import { toast } from 'sonner';
import { useActionRunner, useGlobalUndo, useMutationInvalidationBridge, notifyDataChanged, FilterScopeProvider, resolveApprovalLock, claimAnswerWarnings, toastRecordSave, type AnswerWarningClaim } from '@object-ui/react';
import { useActionRunner, useGlobalUndo, useMutationInvalidationBridge, notifyDataChanged, FilterScopeProvider, resolveApprovalLock, claimAnswerWarnings, toastRecordSave, type AnswerWarningClaim, type RecordSaveFeedback } from '@object-ui/react';
import { useRecordApprovals, openApprovalOf } from '../hooks/useRecordApprovals.js';
import { useObjectTranslation, useObjectLabel, recordSaveMessage as buildRecordSaveMessage } from '@object-ui/i18n';
import type { AppAccessVerdict, ConnectionState } from '@object-ui/data-objectstack';
Expand Down Expand Up @@ -763,9 +763,13 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
runner.registerHandler('crud_success', async (action: any) => {
closeRecordForm();
setRefreshKey(k => k + 1);
// `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);
// `warnings` / `notice`: the save's own advisory warning lines and its
// hook's notice, which this one toast carries
// (objectstack-ai/objectstack#27999, objectstack-ai/objectstack#28252).
toastRecordSave(toast, action.params?.message ?? 'Record saved successfully', {
warnings: action.params?.warnings,
notice: action.params?.notice,
});
return { success: true, reload: true };
});

Expand Down Expand Up @@ -909,10 +913,10 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
};
}, [dataSource, recordFormParam, formObjectName]);

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

Expand All @@ -933,13 +937,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) ?? [];
const feedback = answerWarningClaim.current?.take(saved);
if (isChildFormTask) {
toastRecordSave(toast, recordSaveMessage(saved), warnings);
toastRecordSave(toast, recordSaveMessage(saved), feedback);
closeRecordForm();
return;
}
handleCrudSuccess(saved, warnings);
handleCrudSuccess(saved, feedback);
if (editingRecord || !currentObjectDef) return;
const target = resolvePostCreateTarget({
objectName: currentObjectDef.name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,15 +180,18 @@ const NAMED = { id: 't1', subject: 'Quarterly review' };

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

Expand Down Expand Up @@ -280,3 +283,46 @@ describe('AppContent — an overlay save with warnings shows one record-named to
expect(toastWarning.mock.calls).toEqual([['Saved — with warnings', { description: ADVISORY.message }]]);
});
});

// objectstack-ai/objectstack#28252 — the answer's `notice` (the message a hook
// set, objectstack-ai/objectstack#28000) rides the overlay's claim: the
// `crud_success` toast and the child-form task's direct toast carry it.
describe('AppContent — the overlay save toast carries the hook notice (objectstack-ai/objectstack#28252)', () => {
const NOTICE = 'Sent to your manager for approval';

it('update (crud_success): one success toast, the notice under the record-named message', async () => {
await saveFrom(MAIN_EDIT, update({ warnings: undefined, notice: NOTICE }), NAMED);

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

it('create (crud_success) with a warning too: one warning toast, the notice leading the lines', async () => {
await saveFrom(
MAIN_CREATE,
{ operation: 'create', resource: 'showcase_task', id: 't1', droppedFields: [], warnings: [ADVISORY], notice: NOTICE },
NAMED,
);

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

it('child-form task: the direct toast carries the notice', async () => {
await saveFrom(CHILD_EDIT, update({ warnings: undefined, notice: NOTICE }), NAMED);

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

it('CONTROL: a notice on a write the overlay did not make keeps the channel info toast', async () => {
await saveFrom(MAIN_EDIT, update({ id: 't9', warnings: undefined, notice: NOTICE }), NAMED);

expect(toastSuccess.mock.calls).toEqual([['Task "Quarterly review" updated']]);
expect(toastInfo.mock.calls).toEqual([[NOTICE]]);
});
});
84 changes: 80 additions & 4 deletions ui/packages/app-shell/src/providers/writeWarningToast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,10 @@ const EVENT: WriteWarningEvent = {

/** Records what the shell would have shown. */
function makeSink() {
const calls: Array<{ title: string; description?: string }> = [];
const calls: Array<{ title: string; description?: string; variant?: 'info' }> = [];
const sink: WriteWarningSink = {
warning: (title, options) => { calls.push({ title, description: options?.description }); },
info: (title) => { calls.push({ title, variant: 'info' }); },
};
return { sink, calls };
}
Expand Down Expand Up @@ -510,7 +511,7 @@ describe('claimAnswerWarnings — the save surface reports its write (objectstac

const saved = await ds.update('quote', 'r1', { discount: 40 });
// Taken as the surface's success handler would, before the channel settles.
expect(claim.take(saved)).toEqual([ADVISORY.message]);
expect(claim.take(saved)).toEqual({ warnings: [ADVISORY.message] });
await settle();
claim.release();

Expand All @@ -530,7 +531,7 @@ describe('claimAnswerWarnings — the save surface reports its write (objectstac
await settle();
expect(calls).toEqual([]);

expect(claim.take(saved)).toEqual([ADVISORY.message]);
expect(claim.take(saved)).toEqual({ warnings: [ADVISORY.message] });
expect(calls).toEqual([{ title: WARNED, description: other.message }]);
claim.release();
});
Expand All @@ -547,7 +548,7 @@ describe('claimAnswerWarnings — the save surface reports its write (objectstac
const claim = claimAnswerWarnings(adapter, { operation: 'update', resource: 'andon', id: 'r1' });

const saved = await ds.update('andon', 'r1', { type: 'x', source_method: 'y' });
expect(claim.take(saved)).toEqual([ADVISORY.message]);
expect(claim.take(saved)).toEqual({ warnings: [ADVISORY.message] });
await settle();
claim.release();

Expand Down Expand Up @@ -589,3 +590,78 @@ describe('claimAnswerWarnings — the save surface reports its write (objectstac
expect(calls).toHaveLength(2);
});
});

// ── a write answer's notice rides the same claim (objectstack-ai/objectstack#28252) ──
//
// Same real chain. The notice a hook set (objectstack-ai/objectstack#28000)
// is one toast per write: the surface's when it claims the write, the
// channel's otherwise.

describe('the answer notice — one toast per write (objectstack-ai/objectstack#28252)', () => {
const NOTICE = 'Sent to your manager for approval';
const WARNED = 'Saved — with warnings';

beforeEach(() => {
vi.clearAllMocks();
adapter.getObjectSchema.mockResolvedValue(ANDON_SCHEMA);
});

it('a claimed update: the channel shows nothing, the surface takes the notice with the warnings', async () => {
const update = vi.fn().mockResolvedValue({ record: { id: 'r1' }, warnings: [ADVISORY], notice: NOTICE });
const { ds, calls, settle } = wiredAdapter({ update });
const claim = claimAnswerWarnings(adapter, { operation: 'update', resource: 'deal', id: 'r1' });

const saved = await ds.update('deal', 'r1', { stage: 'won' });
expect(claim.take(saved)).toEqual({ warnings: [ADVISORY.message], notice: NOTICE });
await settle();
claim.release();

expect(calls).toEqual([]);
});

it('a claimed create whose answer carries only a notice: the surface takes it', async () => {
const create = vi.fn().mockResolvedValue({ record: { id: 'd1' }, notice: NOTICE });
const { ds, calls, settle } = wiredAdapter({ create });
const claim = claimAnswerWarnings(adapter, { operation: 'create', resource: 'deal' });

const saved = await ds.create('deal', { stage: 'won' });
expect(claim.take(saved)).toEqual({ warnings: [], notice: NOTICE });
await settle();
claim.release();

expect(calls).toEqual([]);
});

it('unclaimed, a notice alone is one info toast with the server text as written', async () => {
const update = vi.fn().mockResolvedValue({ record: { id: 'r1' }, notice: NOTICE });
const { ds, calls, settle } = wiredAdapter({ update });

await ds.update('deal', 'r1', { stage: 'won' });
await settle();

expect(calls).toEqual([{ title: NOTICE, variant: 'info' }]);
});

it('unclaimed, a notice beside warnings leads the one warnings toast', async () => {
const update = vi.fn().mockResolvedValue({ record: { id: 'r1' }, warnings: [ADVISORY], notice: NOTICE });
const { ds, calls, settle } = wiredAdapter({ update });

await ds.update('deal', 'r1', { stage: 'won' });
await settle();

expect(calls).toEqual([{ title: WARNED, description: `${NOTICE}\n${ADVISORY.message}` }]);
});

it('a claim closed without a take shows the notice it parked on the channel toast', async () => {
const update = vi.fn().mockResolvedValue({ record: { id: 'r1' }, notice: NOTICE });
const { ds, calls, settle } = wiredAdapter({ update });
const claim = claimAnswerWarnings(adapter, { operation: 'update', resource: 'deal', id: 'r1' });

await ds.update('deal', 'r1', { stage: 'won' });
await settle();
expect(calls).toEqual([]);
claim.release();

expect(calls).toEqual([{ title: NOTICE, variant: 'info' }]);
});
});
35 changes: 27 additions & 8 deletions ui/packages/app-shell/src/providers/writeWarningToast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export type FieldLabelFn = (objectName: string, fieldName: string, fallback: str
*/
export interface WriteWarningSink {
warning(title: string, options?: { description?: string }): void;
info(title: string): void;
}

/**
Expand Down Expand Up @@ -251,6 +252,14 @@ function lineFor(reason: DroppedFieldsNotice['reason']): StrippedLine {
* so the claim holds the warnings by the time the surface's success handler
* runs, whatever the label lookup below costs. The stripped-fields toast is
* never claimed.
*
* The answer's `notice` (objectstack-ai/objectstack#28252) — the message a
* hook on the write set for the person who made it — is parked with the
* warnings on the same claim, so a record save surface shows it on its one
* save toast. Unclaimed, it gets the channel's own toast: it leads the
* warnings toast's lines when the write also carried warnings, and is an info
* toast of its own, the server's text as written, when it did not. Either way
* the write's answer feedback is one toast.
*/
export async function emitWriteWarning(
ev: WriteWarningEvent,
Expand All @@ -261,23 +270,33 @@ export async function emitWriteWarning(
): Promise<void> {
// Before the first `await`: see the claim paragraph above.
const messages = answerWarningLines(ev);
const claimed = messages.length > 0 && !!adapter
&& parkAnswerWarnings(adapter, ev, () => emitAnswerWarnings(messages, t, sink));
const notice = ev.notice || undefined;
const claimed = (messages.length > 0 || notice !== undefined) && !!adapter
&& parkAnswerWarnings(adapter, ev, () => emitAnswerFeedback(messages, notice, t, sink));
await emitStrippedFields(ev, t, adapter, fieldLabel, sink);
if (!claimed) emitAnswerWarnings(messages, t, sink);
if (!claimed) emitAnswerFeedback(messages, notice, t, sink);
}

/**
* The write answer's advisory `warnings`, one line per distinct message, under
* one title. Nothing when the event carries none.
* The write answer's advisory `warnings`, one line per distinct message under
* one title, led by its `notice` when there is one; a notice alone is an info
* toast of its own. Nothing when the event carries neither.
*/
function emitAnswerWarnings(messages: string[], t: TranslateFn, sink: WriteWarningSink): void {
if (messages.length === 0) return;
function emitAnswerFeedback(
messages: string[],
notice: string | undefined,
t: TranslateFn,
sink: WriteWarningSink,
): void {
if (messages.length === 0) {
if (notice !== undefined) sink.info(notice);
return;
}
sink.warning(
t('detail.writeWarningsTitle', {
defaultValue: 'Saved — with warnings',
}),
{ description: messages.join('\n') },
{ description: (notice !== undefined ? [notice, ...messages] : messages).join('\n') },
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ import { RecordFormPage } from './RecordFormPage';

const h = React.createElement;

const { toastSuccess, toastWarning, saveState, ADAPTER } = vi.hoisted(() => ({
const { toastSuccess, toastWarning, toastInfo, saveState, ADAPTER } = vi.hoisted(() => ({
toastSuccess: vi.fn(),
toastWarning: vi.fn(),
toastInfo: vi.fn(),
saveState: { event: undefined as unknown, saved: undefined as Record<string, unknown> | undefined },
ADAPTER: { getObjectSchema: async () => ({ fields: {} as Record<string, { label?: string }> }) },
}));
Expand All @@ -50,7 +51,7 @@ vi.mock('sonner', () => ({
toast: Object.assign(vi.fn(), {
success: toastSuccess,
error: vi.fn(),
info: vi.fn(),
info: toastInfo,
warning: toastWarning,
loading: vi.fn(),
dismiss: vi.fn(),
Expand Down Expand Up @@ -125,6 +126,7 @@ const NAMED = { id: 't1', subject: 'Quarterly review' };
beforeEach(() => {
toastSuccess.mockClear();
toastWarning.mockClear();
toastInfo.mockClear();
pending.length = 0;
saveState.event = undefined;
saveState.saved = undefined;
Expand Down Expand Up @@ -217,3 +219,40 @@ describe('RecordFormPage — a save with warnings shows one record-named toast (
expect(toastWarning).not.toHaveBeenCalled();
});
});

// objectstack-ai/objectstack#28252 — the answer's `notice` (the message a hook
// set, objectstack-ai/objectstack#28000) rides the same claim: still one toast.
// Direction, written before the run: with the channel parking warnings only,
// the notice cells RED (an info toast beside a bare success toast).
describe('RecordFormPage — the save toast carries the hook notice (objectstack-ai/objectstack#28252)', () => {
const NOTICE = 'Sent to your manager for approval';

it('update with a notice only: one success toast, record-named, the notice under it', async () => {
await saveOn('edit', update({ warnings: undefined, notice: NOTICE }), NAMED);

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

it('create with a notice and a warning: one warning toast, the notice leading the lines', async () => {
await saveOn(
'create',
{ operation: 'create', resource: 'showcase_task', id: 't1', droppedFields: [], warnings: [ADVISORY], notice: NOTICE },
NAMED,
);

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

it('CONTROL: a notice on a write the page did not make keeps the channel info toast', async () => {
await saveOn('edit', update({ resource: 'showcase_project', id: 'p1', warnings: undefined, notice: NOTICE }), NAMED);

expect(toastSuccess.mock.calls).toEqual([['Task "Quarterly review" updated']]);
expect(toastInfo.mock.calls).toEqual([[NOTICE]]);
});
});
Loading
Loading