diff --git a/.changeset/28252-save-toast-hook-notice.md b/.changeset/28252-save-toast-hook-notice.md new file mode 100644 index 00000000000..aff41290365 --- /dev/null +++ b/.changeset/28252-save-toast-hook-notice.md @@ -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. diff --git a/ui/packages/app-shell/src/console/AppContent.tsx b/ui/packages/app-shell/src/console/AppContent.tsx index 693001002b1..71cfaa1b3b8 100644 --- a/ui/packages/app-shell/src/console/AppContent.tsx +++ b/ui/packages/app-shell/src/console/AppContent.tsx @@ -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'; @@ -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 }; }); @@ -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]); @@ -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, diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.recordSaveOneToast-27999.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.recordSaveOneToast-27999.test.tsx index f94271d1be6..5b50a643f0b 100644 --- a/ui/packages/app-shell/src/console/__tests__/AppContent.recordSaveOneToast-27999.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.recordSaveOneToast-27999.test.tsx @@ -180,15 +180,18 @@ const NAMED = { id: 't1', subject: 'Quarterly review' }; let toastSuccess: ReturnType; let toastWarning: ReturnType; +let toastInfo: ReturnType; 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(); }); @@ -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]]); + }); +}); diff --git a/ui/packages/app-shell/src/providers/writeWarningToast.test.ts b/ui/packages/app-shell/src/providers/writeWarningToast.test.ts index 62e0b2f8750..324aa9c52a3 100644 --- a/ui/packages/app-shell/src/providers/writeWarningToast.test.ts +++ b/ui/packages/app-shell/src/providers/writeWarningToast.test.ts @@ -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 }; } @@ -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(); @@ -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(); }); @@ -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(); @@ -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' }]); + }); +}); diff --git a/ui/packages/app-shell/src/providers/writeWarningToast.ts b/ui/packages/app-shell/src/providers/writeWarningToast.ts index a3c2e5f638d..8b594d9faeb 100644 --- a/ui/packages/app-shell/src/providers/writeWarningToast.ts +++ b/ui/packages/app-shell/src/providers/writeWarningToast.ts @@ -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; } /** @@ -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, @@ -261,23 +270,33 @@ export async function emitWriteWarning( ): Promise { // 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') }, ); } diff --git a/ui/packages/app-shell/src/views/RecordFormPage.saveToastCarriesWarnings-27999.test.tsx b/ui/packages/app-shell/src/views/RecordFormPage.saveToastCarriesWarnings-27999.test.tsx index ac0d8fab187..8e8198bb476 100644 --- a/ui/packages/app-shell/src/views/RecordFormPage.saveToastCarriesWarnings-27999.test.tsx +++ b/ui/packages/app-shell/src/views/RecordFormPage.saveToastCarriesWarnings-27999.test.tsx @@ -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 | undefined }, ADAPTER: { getObjectSchema: async () => ({ fields: {} as Record }) }, })); @@ -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(), @@ -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; @@ -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]]); + }); +}); diff --git a/ui/packages/app-shell/src/views/RecordFormPage.tsx b/ui/packages/app-shell/src/views/RecordFormPage.tsx index d7c3755d5c7..46ea9e6ccc2 100644 --- a/ui/packages/app-shell/src/views/RecordFormPage.tsx +++ b/ui/packages/app-shell/src/views/RecordFormPage.tsx @@ -175,7 +175,7 @@ export function RecordFormPage({ mode }: RecordFormPageProps) { // this page is open, the write-warning channel parks the answer warnings of // the write the page makes (this record's update, or a create of this // object) on the claim below instead of toasting them, and this toast - // carries them. + // carries them — with the answer's notice (objectstack-ai/objectstack#28252). const answerWarningClaim = useRef(null); useEffect(() => { if (!dataSource || !objectName) return; @@ -191,13 +191,13 @@ export function RecordFormPage({ mode }: RecordFormPageProps) { }, [dataSource, mode, objectName, recordId]); const handleSuccess = useCallback((saved?: any) => { - const warnings = answerWarningClaim.current?.take(saved) ?? []; + const feedback = answerWarningClaim.current?.take(saved); toastRecordSave(toast, recordSaveMessage(t, { label, objectDef, saved, mode, - }), warnings); + }), feedback); goBack(); }, [mode, t, label, objectDef, goBack]); diff --git a/ui/packages/data-objectstack/src/index.ts b/ui/packages/data-objectstack/src/index.ts index 8ba471489a6..9c6974d2b75 100644 --- a/ui/packages/data-objectstack/src/index.ts +++ b/ui/packages/data-objectstack/src/index.ts @@ -2335,6 +2335,13 @@ function notifyWithheldMentions( * present only when at least one entry parsed: an answer without the key — * including one from a runtime that predates it — leaves the event exactly as * it was. + * + * `notice` is the same answer's `notice` (objectstack-ai/objectstack#28000): + * the one message a hook on the write set for the person who made it, info + * severity, the server's text as written. It rides this event like `warnings` + * does, so the record save surface that claims its write's answer feedback + * shows it on that write's one toast (objectstack-ai/objectstack#28252). It + * is present only when the answer carried a non-empty string. */ export interface WriteWarningEvent { operation: 'create' | 'update'; @@ -2342,6 +2349,7 @@ export interface WriteWarningEvent { id?: string | number; droppedFields: DroppedFieldsNotice[]; warnings?: ValidateDataIssue[]; + notice?: string; } /** Event listener type for write-warning (dropped-fields) events. */ @@ -2810,6 +2818,17 @@ function readWriteWarnings(result: unknown): ValidateDataIssue[] { return out; } +/** + * Read a single-record write answer's `notice` — the message a hook set for + * the requester (objectstack-ai/objectstack#28000). The spec declares it a + * non-empty string; anything else, the key's absence included, answers + * `undefined`, so an answer from a runtime that predates it adds nothing. + */ +function readWriteNotice(result: unknown): string | undefined { + const raw = (result as { notice?: unknown } | null | undefined)?.notice; + return typeof raw === 'string' && raw !== '' ? raw : undefined; +} + /** * Drop the fields whose strip changed nothing — the caller supplied exactly * the value the record already holds (objectui#3484). @@ -4092,9 +4111,11 @@ export class ObjectStackAdapter implements DataSource { * `object` healed from `resource` — never asserted into the union, and the * entry itself never dropped for them (objectui#4934, `f75810e7c`). * - * The same answer's `warnings` (objectui#12107) rides this one event: it is - * emitted when either list is non-empty, so a write whose only news is an - * advisory rule hit still reaches the subscriber, with `droppedFields: []`. + * The same answer's `warnings` (objectui#12107) and `notice` + * (objectstack-ai/objectstack#28000) ride this one event: it is emitted when + * any of the three is present, so a write whose only news is an advisory rule + * hit or a hook's notice still reaches the subscriber, with + * `droppedFields: []`. */ private notifyDroppedFields( operation: 'create' | 'update', @@ -4114,13 +4135,15 @@ export class ObjectStackAdapter implements DataSource { droppedFields = withoutNoOpDrops(valid, sent, stored); } const warnings = readWriteWarnings(result); - if (droppedFields.length === 0 && warnings.length === 0) return; + const notice = readWriteNotice(result); + if (droppedFields.length === 0 && warnings.length === 0 && notice === undefined) return; this.emitWriteWarning({ operation, resource, ...(id !== undefined ? { id } : {}), droppedFields, ...(warnings.length > 0 ? { warnings } : {}), + ...(notice !== undefined ? { notice } : {}), }); } diff --git a/ui/packages/data-objectstack/src/onWriteWarning.test.ts b/ui/packages/data-objectstack/src/onWriteWarning.test.ts index 419ed088579..2d4df57758e 100644 --- a/ui/packages/data-objectstack/src/onWriteWarning.test.ts +++ b/ui/packages/data-objectstack/src/onWriteWarning.test.ts @@ -372,3 +372,64 @@ describe('ObjectStackAdapter.onWriteWarning — write answer warnings (objectui# ]); }); }); + +// ── a write answer's `notice` (objectstack-ai/objectstack#28252) ─────────── +// +// The spec declares `notice` (one non-empty string a hook set for the +// requester, objectstack-ai/objectstack#28000) on the single-record +// create/update answers. It rides THIS event the way `warnings` does, and +// what the write resolves stays the record. + +describe('ObjectStackAdapter.onWriteWarning — write answer notice (objectstack-ai/objectstack#28252)', () => { + it('emits a create write-warning carrying the notice, and the create still resolves the record', async () => { + const create = vi.fn().mockResolvedValue({ record: { id: 'r1', stage: 'won' }, notice: 'Sent for approval' }); + const ds = makeDS({ create }); + const events: WriteWarningEvent[] = []; + ds.onWriteWarning((e: WriteWarningEvent) => events.push(e)); + + const rec = await ds.create('deal', { stage: 'won' }); + + expect(rec).toEqual({ id: 'r1', stage: 'won' }); + expect(events).toEqual([ + { operation: 'create', resource: 'deal', id: 'r1', droppedFields: [], notice: 'Sent for approval' }, + ]); + }); + + it('emits an update write-warning carrying the notice beside the answer warnings', async () => { + const update = vi + .fn() + .mockResolvedValue({ record: { id: 'r1' }, warnings: ONE_WARNING, notice: 'Sent for approval' }); + const ds = makeDS({ update }); + const events: WriteWarningEvent[] = []; + ds.onWriteWarning((e: WriteWarningEvent) => events.push(e)); + + const rec = await ds.update('deal', 'r1', { stage: 'won' }); + + expect(rec).toEqual({ id: 'r1' }); + expect(events).toEqual([ + { + operation: 'update', + resource: 'deal', + id: 'r1', + droppedFields: [], + warnings: ONE_WARNING, + notice: 'Sent for approval', + }, + ]); + }); + + it('CONTROL: an empty or non-string notice emits nothing', async () => { + const create = vi + .fn() + .mockResolvedValueOnce({ record: { id: 'r1' }, notice: '' }) + .mockResolvedValueOnce({ record: { id: 'r2' }, notice: 42 }); + const ds = makeDS({ create }); + const events: WriteWarningEvent[] = []; + ds.onWriteWarning((e: WriteWarningEvent) => events.push(e)); + + await ds.create('deal', {}); + await ds.create('deal', {}); + + expect(events).toEqual([]); + }); +}); diff --git a/ui/packages/plugin-form/src/ObjectFormContainer.tsx b/ui/packages/plugin-form/src/ObjectFormContainer.tsx index 4f849d2d9be..31db3c88ce3 100644 --- a/ui/packages/plugin-form/src/ObjectFormContainer.tsx +++ b/ui/packages/plugin-form/src/ObjectFormContainer.tsx @@ -121,7 +121,7 @@ function useContainerSaveFeedback( if (dataSource && writes) { // Taken first, so nothing the label lookup below awaits can come // between the write and its claim. - const warnings = answerWarningClaim.current?.take(saved) ?? []; + const feedback = answerWarningClaim.current?.take(saved); let message = successMessage; if (!message) { // The adapter caches the definition the form body already read. @@ -133,7 +133,7 @@ function useContainerSaveFeedback( mode, }); } - toastRecordSave(toast, message, warnings); + toastRecordSave(toast, message, feedback); } await onSuccess?.(saved); }, [writes, dataSource, mode, successMessage, objectName, onSuccess, objectLabel, t]); diff --git a/ui/packages/plugin-form/src/__tests__/containerSaveToastCarriesWarnings-28216.test.tsx b/ui/packages/plugin-form/src/__tests__/containerSaveToastCarriesWarnings-28216.test.tsx index a97e71f5879..45219d7b735 100644 --- a/ui/packages/plugin-form/src/__tests__/containerSaveToastCarriesWarnings-28216.test.tsx +++ b/ui/packages/plugin-form/src/__tests__/containerSaveToastCarriesWarnings-28216.test.tsx @@ -207,3 +207,63 @@ describe.each(['drawer', 'modal'] as const)('`object-form` `formType: %s` — a expect(channelToast).toHaveBeenCalledTimes(1); }); }); + +// objectstack-ai/objectstack#28252 — the answer's `notice` (the message a hook +// set, objectstack-ai/objectstack#28000) rides the same claim. Direction, +// written before the run: with the claim carrying warnings only, the notice +// rows RED (the channel toasts it, the save toast carries no notice line). +const NOTICE = 'Sent to your manager for approval'; + +/** As `makeDS`, but each write's answer carries `notice` and, when `warned`, the warning. */ +function makeNoticeDS(channelToast: (message: string) => void, warned: boolean) { + const answer = () => ({ notice: NOTICE, ...(warned ? { warnings: [{ message: ADVISORY }] } : {}) }); + const ds: any = { + getObjectSchema: vi.fn().mockResolvedValue(TASK), + findOne: vi.fn().mockResolvedValue({ ...STORED }), + find: vi.fn().mockResolvedValue({ data: [] }), + create: vi.fn(async (o: string, d: Record) => { + const ev = { operation: 'create' as const, resource: o, id: 'n1', ...answer() }; + if (!parkAnswerWarnings(ds, ev, () => channelToast(NOTICE))) channelToast(NOTICE); + return { id: 'n1', ...d }; + }), + update: vi.fn(async (o: string, id: string, d: Record) => { + const ev = { operation: 'update' as const, resource: o, id, ...answer() }; + if (!parkAnswerWarnings(ds, ev, () => channelToast(NOTICE))) channelToast(NOTICE); + return { ...STORED, ...d }; + }), + }; + return ds; +} + +describe.each(['drawer', 'modal'] as const)('`object-form` `formType: %s` — the save toast carries the hook notice', (formType) => { + it('edit with a notice only: one success toast, the notice under the record-named message', async () => { + const channelToast = vi.fn(); + const onSuccess = vi.fn(); + const ds = makeNoticeDS(channelToast, false); + render(); + await waitFor(async () => expect((await input('priority')).value).toBe('low')); + await fillAndSubmit({ priority: 'high' }); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + expect(toastSuccess).toHaveBeenCalledTimes(1); + expect(toastSuccess).toHaveBeenCalledWith('Task "Fix login" updated', { description: NOTICE }); + expect(toastWarning).not.toHaveBeenCalled(); + expect(channelToast).not.toHaveBeenCalled(); + }); + + it('create with a notice and a warning: one warning toast, the notice leading the lines', async () => { + const channelToast = vi.fn(); + const onSuccess = vi.fn(); + const ds = makeNoticeDS(channelToast, true); + render(); + await fillAndSubmit({ title: 'Write release notes' }); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + expect(toastWarning).toHaveBeenCalledTimes(1); + expect(toastWarning).toHaveBeenCalledWith('Task "Write release notes" created', { + description: `${NOTICE}\n${ADVISORY}`, + }); + expect(toastSuccess).not.toHaveBeenCalled(); + expect(channelToast).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/packages/plugin-grid/src/ObjectGrid.tsx b/ui/packages/plugin-grid/src/ObjectGrid.tsx index 0b0027d1816..8464ccafe5e 100644 --- a/ui/packages/plugin-grid/src/ObjectGrid.tsx +++ b/ui/packages/plugin-grid/src/ObjectGrid.tsx @@ -25,7 +25,7 @@ import React, { useEffect, useState, useCallback, useMemo } from 'react'; import type { ObjectGridSchema, DataSource, ViewData, TableSortItem, DataTableSchema, ListViewExportFormat, RecordNavigateAction } from '@object-ui/types'; import { isSystemManagedField } from '@object-ui/types'; import { resolveFieldScale } from '@objectstack/spec/data'; -import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction, useSafeFieldLabel, usePredicateScope, useCapabilityGate, notifyDataChanged, useMetadata } from '@object-ui/react'; +import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction, useSafeFieldLabel, usePredicateScope, useCapabilityGate, notifyDataChanged, useMetadata, claimAnswerWarnings, toastRecordSave, type AnswerWarningClaim, type RecordSaveFeedback } from '@object-ui/react'; // objectui#8920 — the grid reaches a cell renderer through THIS module and // nowhere else. `getCellRenderer` / `resolveCellRendererType` are deliberately // NOT imported here: six sites spelling the resolve three different ways is @@ -2311,19 +2311,27 @@ export const ObjectGrid: React.FC = ({ if (id === undefined || id === null) { throw new Error('Cannot persist inline edit: row has no id/_id.'); } + // objectstack-ai/objectstack#28252 — the row's write answer feedback (its + // warnings and its hook's notice) rides the save toast below, so the + // write is one toast. + const claim = claimAnswerWarnings(dataSource, { operation: 'update', resource: objectName, id }); try { - await writeRowGuarded(dataSource, objectName, id, changes, row); - } catch (err) { - if (!isConcurrentUpdateError(err)) throw err; - // A reload re-reads through the bus; there is nothing of ours to show. - if ((await resolveRowConflict(dataSource, objectName, id, changes, row, err)) === 'reloaded') return; + try { + await writeRowGuarded(dataSource, objectName, id, changes, row); + } catch (err) { + if (!isConcurrentUpdateError(err)) throw err; + // A reload re-reads through the bus; there is nothing of ours to show. + if ((await resolveRowConflict(dataSource, objectName, id, changes, row, err)) === 'reloaded') return; + } + // Refresh so the grid shows the persisted values. + setRefreshKey(k => k + 1); + // objectui#12303 — the write is said, through the console's shared toast + // (the one the form, kanban and view surfaces raise). Only here, once the + // row landed: a refusal rejects above, and the table flags the row. + toastRecordSave(toast, t('table.saved', { count: 1 }), claim.take(undefined)); + } finally { + claim.release(); } - // Refresh so the grid shows the persisted values. - setRefreshKey(k => k + 1); - // objectui#12303 — the write is said, through the console's shared toast - // (the one the form, kanban and view surfaces raise). Only here, once the - // row landed: a refusal rejects above, and the table flags the row. - toast.success(t('table.saved', { count: 1 })); }; const defaultBatchSave = async ( @@ -2343,6 +2351,26 @@ export const ObjectGrid: React.FC = ({ // that landed are reported as saved, and only the rows that did not are // handed back to the table as `failed`. const ids = changes.map(({ row }) => resolveRecordId(row)); + // objectstack-ai/objectstack#28252 — each row's write answer feedback rides + // the one "Save All" toast below, as the single-row save's does. + const claims: AnswerWarningClaim[] = ids.flatMap((id) => + id === undefined || id === null ? [] : [claimAnswerWarnings(dataSource, { operation: 'update', resource: objectName, id })], + ); + try { + return await saveRowsClaimed(dataSource, objectName, changes, ids, claims); + } finally { + for (const claim of claims) claim.release(); + } + }; + + /** The body of {@link defaultBatchSave}, run while its rows' claims are open. */ + const saveRowsClaimed = async ( + dataSource: DataSource, + objectName: string, + changes: Array<{ rowIndex: number; changes: Record; row: any }>, + ids: Array, + claims: AnswerWarningClaim[], + ): Promise => { const attempts = await Promise.allSettled( changes.map(({ changes: rowChanges, row }, i) => { const id = ids[i]; @@ -2383,7 +2411,16 @@ export const ObjectGrid: React.FC = ({ // shared toast, counting only the rows the server accepted. A row that // failed is not in the count: the table keeps it, flags it and shows // the reason. A reloaded row wrote nothing, so it is not either. - toast.success(t('table.saved', { count: saved })); + // A row that wrote nothing parked nothing, so every claim is read. + const feedback: RecordSaveFeedback = { warnings: [] }; + const notices: string[] = []; + for (const claim of claims) { + const own = claim.take(undefined); + for (const w of own.warnings) if (!feedback.warnings.includes(w)) feedback.warnings.push(w); + if (own.notice && !notices.includes(own.notice)) notices.push(own.notice); + } + if (notices.length > 0) feedback.notice = notices.join('\n'); + toastRecordSave(toast, t('table.saved', { count: saved }), feedback); } return failed.length > 0 ? { failed } : undefined; }; diff --git a/ui/packages/plugin-grid/src/__tests__/inlineEditSaveNotice-28252.test.tsx b/ui/packages/plugin-grid/src/__tests__/inlineEditSaveNotice-28252.test.tsx new file mode 100644 index 00000000000..c9235afeb27 --- /dev/null +++ b/ui/packages/plugin-grid/src/__tests__/inlineEditSaveNotice-28252.test.tsx @@ -0,0 +1,196 @@ +/** + * 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#28252 — the grid's inline-edit saves carry the + * write answer's `notice` (the message a hook set, + * objectstack-ai/objectstack#28000) on their own save toast, so the write is + * one toast: the per-row save and "Save All" claim their rows' answer + * feedback, and the write-warning channel raises none of its own. + * + * The channel is stood in for by its own door, `parkAnswerWarnings` from + * `@object-ui/react`, called synchronously inside the write as the adapter's + * write-warning event is; `channelToast` is the toast the channel would show. + * + * Direction, written before the run: with the grid's claims removed, every + * "one toast" row RED (the channel toasts the notice, the save toast carries + * no notice line); the CONTROL row GREEN on both sides. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +// The `toast` this grid raises is `@object-ui/components`' re-export of +// sonner's. `plugin-grid` does not depend on `sonner`, so a bare +// `vi.mock('sonner')` here would register against an id nothing in this +// package resolves to (the trap `WizardForm.outcomeToastSupersede.test.tsx` +// measured); the components entry is the module the grid imports it from. +const toasts = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() })); +vi.mock('@object-ui/components', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, toast: Object.assign(vi.fn(), toasts) }; +}); + +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider, parkAnswerWarnings } from '@object-ui/react'; +import { ObjectGrid } from '../ObjectGrid'; +import { __clearRecordCrudVerdictCache } from '../hooks/useRecordCrudVerdicts'; +import { installExplainDouble } from './explainDouble'; + +registerAllFields(); + +beforeAll(() => { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as unknown as Element['scrollIntoView']; + } +}); +beforeEach(() => { + __clearRecordCrudVerdictCache(); + installExplainDouble(); + for (const fn of Object.values(toasts)) fn.mockClear(); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +const OBJECT = 'task_28252'; +const NOTICE = 'Sent to your manager for approval'; + +/** + * An in-memory data source whose writes answer `notice` (and, for the ids in + * `warned`, a warning), announced the way the adapter's write-warning event + * is: inside the write, before its promise settles. + */ +function makeDataSource(channelToast: (line: string) => void, warned: Set = new Set()) { + const store: Record> = { + t1: { id: 't1', title: 'Ingest pipeline', estimate_hours: 60 }, + t2: { id: 't2', title: 'Warehouse schema', estimate_hours: 33 }, + }; + const ds = { + store, + find: vi.fn(async () => { + const data = Object.values(store).map((r) => ({ ...r })); + return { data, total: data.length, hasMore: false, pageSize: 50 }; + }), + update: vi.fn(async (object: string, id: string, changes: Record) => { + store[id] = { ...store[id], ...changes }; + const ev = { + operation: 'update' as const, + resource: object, + id, + notice: NOTICE, + ...(warned.has(id) ? { warnings: [{ message: `Estimate is high on ${id}` }] } : {}), + }; + if (!parkAnswerWarnings(ds, ev, () => channelToast(NOTICE))) channelToast(NOTICE); + return { ...store[id] }; + }), + getObjectSchema: vi.fn(async (name: string) => ({ + name, + fields: { + id: { type: 'text' }, + title: { type: 'text', label: 'Title' }, + estimate_hours: { type: 'number', label: 'Estimate' }, + }, + })), + }; + return ds; +} + +type GridProps = React.ComponentProps; + +async function loaded(dataSource: ReturnType) { + const schema = { + type: 'object-grid', + objectName: OBJECT, + editable: true, + singleClickEdit: true, + columns: [ + { field: 'title', label: 'Title', editable: false }, + { field: 'estimate_hours', label: 'Estimate', type: 'number' }, + ], + pagination: { pageSize: 50 }, + } as GridProps['schema']; + render( + + {/* Row Edit/Delete handlers: the per-row save button exists. */} + {}} + onDelete={() => {}} + /> + , + ); + await waitFor(() => expect(screen.getByText('Ingest pipeline')).toBeInTheDocument()); +} + +const rowOf = (title: string) => screen.getByText(title).closest('tr') as HTMLElement; + +/** Stage a new Estimate on the row titled `title`, the way a user types it. */ +async function editEstimate(title: string, from: string, to: string) { + const td = await waitFor(() => { + const cell = Array.from(rowOf(title).querySelectorAll('td')).find((c) => c.textContent?.trim() === from); + if (!cell) throw new Error(`no ${from} cell on ${title}`); + return cell as HTMLElement; + }); + fireEvent.click(td); + const input = await waitFor(() => { + const el = td.querySelector('input') as HTMLInputElement | null; + if (!el) throw new Error('editor not open'); + return el; + }); + fireEvent.change(input, { target: { value: to } }); + fireEvent.keyDown(input, { key: 'Enter' }); + await waitFor(() => expect(within(rowOf(title)).getByText(to)).toBeInTheDocument()); +} + +const clickSaveAll = () => fireEvent.click(screen.getByRole('button', { name: /Save All/ })); + +describe('ObjectGrid inline-edit saves carry the hook notice (objectstack-ai/objectstack#28252)', () => { + it('the per-row save: one success toast, the notice under "1 row saved"; the channel shows nothing', async () => { + const channelToast = vi.fn(); + const ds = makeDataSource(channelToast); + await loaded(ds); + await editEstimate('Ingest pipeline', '60', '61'); + fireEvent.click(within(rowOf('Ingest pipeline')).getByTitle('Save row')); + + await waitFor(() => expect(toasts.success).toHaveBeenCalledTimes(1)); + expect(toasts.success).toHaveBeenCalledWith('1 row saved', { description: NOTICE }); + expect(toasts.warning).not.toHaveBeenCalled(); + expect(channelToast).not.toHaveBeenCalled(); + }); + + it('"Save All" over two rows: one toast, the shared notice once, the warning lines under it', async () => { + const channelToast = vi.fn(); + const ds = makeDataSource(channelToast, new Set(['t2'])); + await loaded(ds); + await editEstimate('Ingest pipeline', '60', '61'); + await editEstimate('Warehouse schema', '33', '31'); + clickSaveAll(); + + await waitFor(() => expect(toasts.warning).toHaveBeenCalledTimes(1)); + expect(toasts.warning).toHaveBeenCalledWith('2 rows saved', { description: `${NOTICE}\nEstimate is high on t2` }); + expect(toasts.success).not.toHaveBeenCalled(); + expect(channelToast).not.toHaveBeenCalled(); + }); + + it('CONTROL: a write the grid did not make keeps the channel toast once the save is over', async () => { + const channelToast = vi.fn(); + const ds = makeDataSource(channelToast); + await loaded(ds); + await editEstimate('Ingest pipeline', '60', '61'); + fireEvent.click(within(rowOf('Ingest pipeline')).getByTitle('Save row')); + await waitFor(() => expect(toasts.success).toHaveBeenCalledTimes(1)); + + await ds.update(OBJECT, 't1', { estimate_hours: 62 }); + expect(channelToast).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/packages/react/src/record-save-feedback.test.ts b/ui/packages/react/src/record-save-feedback.test.ts index b063263b90f..96b424906a9 100644 --- a/ui/packages/react/src/record-save-feedback.test.ts +++ b/ui/packages/react/src/record-save-feedback.test.ts @@ -30,7 +30,7 @@ describe('parkAnswerWarnings', () => { const show = vi.fn(); expect(parkAnswerWarnings(adapter, ev(), show)).toBe(true); - expect(claim.take({ id: 'r1' })).toEqual(['Discount above 30%']); + expect(claim.take({ id: 'r1' })).toEqual({ warnings: ['Discount above 30%'] }); claim.release(); expect(show).not.toHaveBeenCalled(); }); @@ -47,11 +47,49 @@ describe('parkAnswerWarnings', () => { }); }); +describe('the answer notice (objectstack-ai/objectstack#28252)', () => { + it('rides the claim beside the warnings; the last notice parked for the write wins', () => { + const adapter = {}; + const claim = claimAnswerWarnings(adapter, { operation: 'update', resource: 'deal', id: 'r1' }); + const show = vi.fn(); + + expect(parkAnswerWarnings(adapter, ev({ resource: 'deal', warnings: undefined, notice: 'First' }), show)).toBe(true); + expect(parkAnswerWarnings(adapter, ev({ resource: 'deal', notice: 'Sent for approval' }), show)).toBe(true); + expect(claim.take({ id: 'r1' })).toEqual({ warnings: ['Discount above 30%'], notice: 'Sent for approval' }); + claim.release(); + expect(show).not.toHaveBeenCalled(); + }); + + it("CONTROL: a create's notice is its own only when the answer's record is the saved one", () => { + const adapter = {}; + const claim = claimAnswerWarnings(adapter, { operation: 'create', resource: 'deal' }); + const other = vi.fn(); + parkAnswerWarnings(adapter, ev({ operation: 'create', resource: 'deal', id: 'r9', warnings: undefined, notice: 'Not mine' }), other); + + expect(claim.take({ id: 'r1' })).toEqual({ warnings: [] }); + expect(other).toHaveBeenCalledTimes(1); + claim.release(); + }); + + it('the toast keeps the record-named title and carries the notice; a notice alone stays a success', () => { + const sink = { success: vi.fn(), warning: vi.fn() }; + toastRecordSave(sink, 'Deal "Acme" updated', { warnings: [], notice: 'Sent for approval' }); + toastRecordSave(sink, 'Deal "Acme" updated', { warnings: ['Discount above 30%'], notice: 'Sent for approval' }); + + expect(sink.success).toHaveBeenCalledTimes(1); + expect(sink.success).toHaveBeenCalledWith('Deal "Acme" updated', { description: 'Sent for approval' }); + expect(sink.warning).toHaveBeenCalledTimes(1); + expect(sink.warning).toHaveBeenCalledWith('Deal "Acme" updated', { + description: 'Sent for approval\nDiscount above 30%', + }); + }); +}); + describe('toastRecordSave', () => { it('the success variant without warnings, the warning variant carrying them one per line', () => { const sink = { success: vi.fn(), warning: vi.fn() }; toastRecordSave(sink, 'Quote "Q-1" updated'); - toastRecordSave(sink, 'Quote "Q-1" updated', ['a', 'b']); + toastRecordSave(sink, 'Quote "Q-1" updated', { warnings: ['a', 'b'] }); expect(sink.success).toHaveBeenCalledWith('Quote "Q-1" updated'); expect(sink.warning).toHaveBeenCalledWith('Quote "Q-1" updated', { description: 'a\nb' }); diff --git a/ui/packages/react/src/record-save-feedback.ts b/ui/packages/react/src/record-save-feedback.ts index 06e1b7e59fd..995c453403f 100644 --- a/ui/packages/react/src/record-save-feedback.ts +++ b/ui/packages/react/src/record-save-feedback.ts @@ -16,7 +16,10 @@ * success toast. While a save surface is open it CLAIMS the warnings of the * write it makes ({@link claimAnswerWarnings}); the channel parks them on that * claim ({@link parkAnswerWarnings}) instead of raising its own toast, and the - * surface's toast carries them ({@link toastRecordSave}). + * surface's toast carries them ({@link toastRecordSave}). The answer's + * `notice` — the one message a hook on the write set for the person who made + * it (objectstack-ai/objectstack#28000) — rides the same claim, so a save + * still raises one toast (objectstack-ai/objectstack#28252). * * It lives here, below every save surface — `app-shell`'s record page and * record dialogs, and `plugin-form`'s `object-form` drawer and modal — so @@ -35,6 +38,7 @@ export interface AnswerWarningsEvent { resource: string; id?: string | number; warnings?: readonly { message?: string }[]; + notice?: string; } /** @@ -48,15 +52,25 @@ export interface AnswerWarningScope { id?: string | number; } +/** + * What a record save's answer tells the person beyond "saved": its advisory + * warning messages, distinct, and the notice a hook set, if any. + */ +export interface RecordSaveFeedback { + warnings: string[]; + notice?: string; +} + /** A record save surface's hold on the answer warnings of its own write. */ export interface AnswerWarningClaim { /** - * The distinct warning messages of the write that answered `saved`, removed - * from the claim. Every other event the claim parked is shown on the - * channel's own toast at once, so a write the surface did not make keeps - * the toast it always had. + * The answer feedback of the write that answered `saved`, removed from the + * claim: its distinct warning messages and its notice (the last one parked + * wins, as the last `onNotice` call does on the server). Every other event + * the claim parked is shown on the channel's own toast at once, so a write + * the surface did not make keeps the toast it always had. */ - take(saved: unknown): string[]; + take(saved: unknown): RecordSaveFeedback; /** Close the claim; whatever it still parks is shown on the channel's toast. */ release(): void; } @@ -139,11 +153,12 @@ export function claimAnswerWarnings(adapter: object, scope: AnswerWarningScope): const others = entry.parked.filter((p) => !own.includes(p)); entry.parked = []; showAll(others); - const messages: string[] = []; + const feedback: RecordSaveFeedback = { warnings: [] }; for (const p of own) { - for (const m of answerWarningLines(p.ev)) if (!messages.includes(m)) messages.push(m); + for (const m of answerWarningLines(p.ev)) if (!feedback.warnings.includes(m)) feedback.warnings.push(m); + if (p.ev.notice) feedback.notice = p.ev.notice; } - return messages; + return feedback; }, release() { open?.delete(entry); @@ -156,16 +171,24 @@ export function claimAnswerWarnings(adapter: object, scope: AnswerWarningScope): /** Where a record save toast goes. Structurally satisfied by sonner's `toast`. */ export interface RecordSaveSink { - success(title: string): void; + success(title: string, options?: { description?: string }): void; warning(title: string, options?: { description?: string }): void; } /** - * The one toast of a record save: the surface's record-named message, carrying - * its write's advisory warnings one per line on the warning variant when there - * are any. + * The one toast of a record save: the surface's record-named message as its + * title, and under it the write's notice and then its advisory warnings, one + * per line. Any warning puts it on the warning variant; a notice alone keeps + * it a success, because a notice reports an outcome, never a problem. */ -export function toastRecordSave(sink: RecordSaveSink, message: string, warnings: readonly string[] = []): void { - if (warnings.length > 0) sink.warning(message, { description: warnings.join('\n') }); +export function toastRecordSave( + sink: RecordSaveSink, + message: string, + feedback: Partial = {}, +): void { + const warnings = feedback.warnings ?? []; + const lines = feedback.notice ? [feedback.notice, ...warnings] : [...warnings]; + if (warnings.length > 0) sink.warning(message, { description: lines.join('\n') }); + else if (lines.length > 0) sink.success(message, { description: lines.join('\n') }); else sink.success(message); }