diff --git a/.changeset/28216-form-container-one-save-toast.md b/.changeset/28216-form-container-one-save-toast.md new file mode 100644 index 00000000000..98e948928bc --- /dev/null +++ b/.changeset/28216-form-container-one-save-toast.md @@ -0,0 +1,15 @@ +--- +'@object-ui/react': patch +'@object-ui/plugin-form': patch +'@object-ui/app-shell': patch +--- + +fix(plugin-form): a drawer or modal `object-form` save that answers warnings shows one toast, the record-named message carrying them + +Clause-②: yes (widening) +Metadata-protocol: none + +A save from an `object-form` block with `formType: 'drawer'` or `'modal'` that succeeded with advisory warnings (a warning-severity validation rule, for example) showed two toasts: the record-named success one and the write-warning channel's own "Saved — with warnings". The record page and the Console's record dialogs already showed one. The container now does the same: while it is open it claims the answer warnings of the write it makes (its record's update, or a create of its object), and its save toast carries them on the warning variant, one per line, whether the message is the record-named one or a declared `successMessage`. A write the container did not make keeps the channel's toast, and so does a container with a `submitHandler`, which writes nothing itself. + +- **`@object-ui/react`**: new exports `claimAnswerWarnings`, `parkAnswerWarnings`, `answerWarningLines`, `toastRecordSave` and the types `AnswerWarningsEvent`, `AnswerWarningScope`, `AnswerWarningClaim` and `RecordSaveSink`. They are the one claim registry every record save surface and the write-warning channel share, moved here from `@object-ui/app-shell`, where they were internal, so `@object-ui/plugin-form` can reach them. +- **`@object-ui/app-shell`**: the write-warning channel, the record page and the record dialogs read the claim from `@object-ui/react`. Their behaviour is unchanged. diff --git a/ui/packages/app-shell/src/console/AppContent.tsx b/ui/packages/app-shell/src/console/AppContent.tsx index b619aa0f917..693001002b1 100644 --- a/ui/packages/app-shell/src/console/AppContent.tsx +++ b/ui/packages/app-shell/src/console/AppContent.tsx @@ -14,14 +14,13 @@ 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 } from '@object-ui/react'; +import { useActionRunner, useGlobalUndo, useMutationInvalidationBridge, notifyDataChanged, FilterScopeProvider, resolveApprovalLock, claimAnswerWarnings, toastRecordSave, type AnswerWarningClaim } 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'; 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 { diff --git a/ui/packages/app-shell/src/providers/writeWarningToast.test.ts b/ui/packages/app-shell/src/providers/writeWarningToast.test.ts index f2fe3c2645b..62e0b2f8750 100644 --- a/ui/packages/app-shell/src/providers/writeWarningToast.test.ts +++ b/ui/packages/app-shell/src/providers/writeWarningToast.test.ts @@ -36,7 +36,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { DroppedFieldsEventSchema } from '@objectstack/spec/data'; -import { claimAnswerWarnings, emitWriteWarning, type WriteWarningSink } from './writeWarningToast'; +import { emitWriteWarning, type WriteWarningSink } from './writeWarningToast'; +import { claimAnswerWarnings } from '@object-ui/react'; import { ObjectStackAdapter } from '@object-ui/data-objectstack'; import { createI18n, loadBuiltInLocale, type TranslateFn } from '@object-ui/i18n'; import type { DroppedFieldsEvent, WriteWarningEvent } from '@object-ui/data-objectstack'; diff --git a/ui/packages/app-shell/src/providers/writeWarningToast.ts b/ui/packages/app-shell/src/providers/writeWarningToast.ts index af23c2681f5..a3c2e5f638d 100644 --- a/ui/packages/app-shell/src/providers/writeWarningToast.ts +++ b/ui/packages/app-shell/src/providers/writeWarningToast.ts @@ -8,9 +8,11 @@ /** * Turning a write-warning (objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455) into the message the user - * reads. Lives apart from `AdapterProvider` — and, deliberately, imports NOTHING - * that renders — so the wording, the label resolution and the reason-branching - * can be exercised directly. + * reads. Lives apart from `AdapterProvider` — and, deliberately, imports no + * toaster (the caller owns the sink) — so the wording, the label resolution and + * the reason-branching can be exercised directly. The record save surfaces' + * claim on answer warnings is `@object-ui/react`'s `record-save-feedback`, + * shared with the `object-form` drawer and modal (objectstack-ai/objectstack#28216). * * @module providers/writeWarningToast */ @@ -22,6 +24,7 @@ import type { WriteWarningEvent, } from '@object-ui/data-objectstack'; import type { TranslateFn } from '@object-ui/i18n'; +import { answerWarningLines, parkAnswerWarnings } from '@object-ui/react'; /** * i18next's `t`, narrowed to what this module uses — RE-EXPORTED from its one @@ -239,8 +242,9 @@ function lineFor(reason: DroppedFieldsNotice['reason']): StrippedLine { * did. * * Except for a write a record save surface reports itself - * (objectstack-ai/objectstack#27999): when an open {@link claimAnswerWarnings} - * claim on `adapter` matches the event, its `warnings` are parked on that claim + * (objectstack-ai/objectstack#27999): when an open `claimAnswerWarnings` + * claim on `adapter` (`@object-ui/react`, objectstack-ai/objectstack#28216) + * matches the event, its `warnings` are parked on that claim * instead, and the surface's own record-named save toast carries them. The * match is decided SYNCHRONOUSLY, before this function's first `await`: the * adapter emits the event inside the write, before the write's promise settles, @@ -257,19 +261,10 @@ export async function emitWriteWarning( ): Promise { // Before the first `await`: see the claim paragraph above. const messages = answerWarningLines(ev); - const claim = messages.length > 0 && adapter ? matchingClaim(adapter, ev) : undefined; - if (claim) claim.parked.push({ ev, show: () => emitAnswerWarnings(messages, t, sink) }); + const claimed = messages.length > 0 && !!adapter + && parkAnswerWarnings(adapter, ev, () => emitAnswerWarnings(messages, t, sink)); await emitStrippedFields(ev, t, adapter, fieldLabel, sink); - if (!claim) emitAnswerWarnings(messages, t, sink); -} - -/** The write answer's advisory `warnings`, one entry per distinct message. */ -function answerWarningLines(ev: WriteWarningEvent): string[] { - const messages: string[] = []; - for (const w of ev.warnings ?? []) { - if (w.message && !messages.includes(w.message)) messages.push(w.message); - } - return messages; + if (!claimed) emitAnswerWarnings(messages, t, sink); } /** @@ -286,111 +281,6 @@ function emitAnswerWarnings(messages: string[], t: TranslateFn, sink: WriteWarni ); } -/** - * The writes a record save surface reports itself: the record form an open - * overlay or page saves (objectstack-ai/objectstack#27999). An update names - * the record it writes; a create's record id is only known from its answer. - */ -export interface AnswerWarningScope { - operation: WriteWarningEvent['operation']; - resource: string; - id?: string | number; -} - -/** 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. - */ - take(saved: unknown): string[]; - /** Close the claim; whatever it still parks is shown on the channel's toast. */ - release(): void; -} - -interface ParkedAnswerWarnings { - ev: WriteWarningEvent; - show: () => void; -} - -interface ClaimEntry { - scope: AnswerWarningScope; - parked: ParkedAnswerWarnings[]; -} - -/** Open claims per adapter instance: the one `AdapterProvider` subscribes to. */ -const CLAIMS = new WeakMap>(); - -const sameId = (a: unknown, b: unknown): boolean => - a === undefined || a === null ? b === undefined || b === null : String(a) === String(b); - -function matchingClaim(adapter: object, ev: WriteWarningEvent): ClaimEntry | undefined { - for (const entry of CLAIMS.get(adapter) ?? []) { - const { scope } = entry; - if (scope.operation !== ev.operation || scope.resource !== ev.resource) continue; - if (scope.operation === 'update' && !sameId(scope.id, ev.id)) continue; - return entry; - } - return undefined; -} - -/** - * Claim the answer warnings of the writes `scope` names on `adapter`, for as - * long as the surface that will report them is open. The surface reads its - * write's warnings with {@link AnswerWarningClaim.take} when the save succeeds - * and closes the claim with {@link AnswerWarningClaim.release} when it closes; - * no parked warning is dropped by either. - */ -export function claimAnswerWarnings(adapter: object, scope: AnswerWarningScope): AnswerWarningClaim { - const entry: ClaimEntry = { scope, parked: [] }; - let open = CLAIMS.get(adapter); - if (!open) CLAIMS.set(adapter, (open = new Set())); - open.add(entry); - const showAll = (list: ParkedAnswerWarnings[]) => { - for (const p of list) p.show(); - }; - return { - take(saved) { - const record = saved && typeof saved === 'object' ? (saved as { id?: unknown; _id?: unknown }) : undefined; - const savedId = record?.id ?? record?._id; - // An update's parked events already name its record; a create's are its - // own only when the answer's record id is the saved record's. - const own = entry.parked.filter((p) => scope.operation === 'update' || sameId(p.ev.id, savedId)); - const others = entry.parked.filter((p) => !own.includes(p)); - entry.parked = []; - showAll(others); - const messages: string[] = []; - for (const p of own) { - for (const m of answerWarningLines(p.ev)) if (!messages.includes(m)) messages.push(m); - } - return messages; - }, - release() { - open?.delete(entry); - const left = entry.parked; - entry.parked = []; - showAll(left); - }, - }; -} - -/** Where a record save toast goes. Structurally satisfied by sonner's `toast`. */ -export interface RecordSaveSink extends WriteWarningSink { - success(title: string): void; -} - -/** - * The one toast of a record save (objectstack-ai/objectstack#27999): the - * surface's record-named message, carrying its write's advisory warnings one - * per line on the warning variant when there are any. - */ -export function toastRecordSave(sink: RecordSaveSink, message: string, warnings: readonly string[] = []): void { - if (warnings.length > 0) sink.warning(message, { description: warnings.join('\n') }); - else sink.success(message); -} - /** The stripped-fields toast — the channel's original and unchanged message. */ async function emitStrippedFields( ev: WriteWarningEvent, diff --git a/ui/packages/app-shell/src/views/RecordFormPage.tsx b/ui/packages/app-shell/src/views/RecordFormPage.tsx index fd4ca8ca640..d7c3755d5c7 100644 --- a/ui/packages/app-shell/src/views/RecordFormPage.tsx +++ b/ui/packages/app-shell/src/views/RecordFormPage.tsx @@ -37,7 +37,7 @@ import { toast } from 'sonner'; import { useObjectTranslation, useObjectLabel, recordSaveMessage } from '@object-ui/i18n'; import { useMetadata } from '../providers/MetadataProvider.js'; import { useAdapter } from '../providers/AdapterProvider.js'; -import { claimAnswerWarnings, toastRecordSave, type AnswerWarningClaim } from '../providers/writeWarningToast.js'; +import { claimAnswerWarnings, toastRecordSave, type AnswerWarningClaim } from '@object-ui/react'; import { ExpressionProvider, createExpressionEvaluator, diff --git a/ui/packages/plugin-form/src/ObjectFormContainer.tsx b/ui/packages/plugin-form/src/ObjectFormContainer.tsx index 23f96fc81b2..4f849d2d9be 100644 --- a/ui/packages/plugin-form/src/ObjectFormContainer.tsx +++ b/ui/packages/plugin-form/src/ObjectFormContainer.tsx @@ -31,12 +31,17 @@ * (`recordSaveMessage`, `Task "Quarterly review" updated`), before the author's * `onSuccess` runs. A host that mounts `DrawerForm` / `ModalForm` itself owns * its own feedback, so it never passes through here and is never toasted twice. + * It is the save's ONE toast (objectstack-ai/objectstack#28216): while the + * container is open it claims the answer warnings of the write it makes, as + * the record page and the console's record dialogs do, and the record-named + * toast carries them instead of the write-warning channel raising a second. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import type { ObjectFormSchema } from '@object-ui/types'; import { toast } from '@object-ui/components'; import { createSafeTranslation, recordSaveMessage, useObjectLabel } from '@object-ui/i18n'; +import { claimAnswerWarnings, toastRecordSave, type AnswerWarningClaim } from '@object-ui/react'; import { usePreparedFormSchema, ObjectForm, type ObjectFormComponentProps } from './ObjectForm'; import { DrawerForm } from './DrawerForm'; import { ModalForm } from './ModalForm'; @@ -83,6 +88,11 @@ const useSaveFeedbackTranslation = createSafeTranslation( * author's `onSuccess`. Only a write the container made is toasted — a * `submitHandler` owns its own persistence and feedback, and an adapter-less * inline-fields collector wrote nothing (its `onSuccess` IS the write). + * + * The toast carries the write's advisory warnings: while the container is + * open, the write-warning channel parks the answer warnings of the write it + * makes (this record's update, or a create of this object) on the claim below + * instead of toasting them. Keyed on primitives (ui/AGENTS.md #10). */ function useContainerSaveFeedback( schema: ReturnType['schema'], @@ -90,9 +100,28 @@ function useContainerSaveFeedback( ): (saved: unknown) => Promise { const { t } = useSaveFeedbackTranslation(); const { objectLabel } = useObjectLabel(); - const { onSuccess, submitHandler, objectName, mode, successMessage } = schema; + const { onSuccess, submitHandler, objectName, mode, recordId, successMessage } = schema; + const isOpen = schema.open !== false; + const writes = !!dataSource && !submitHandler && (mode === 'create' || mode === 'edit'); + const answerWarningClaim = useRef(null); + useEffect(() => { + if (!dataSource || !writes || !isOpen || !objectName) return; + if (mode === 'edit' && !recordId) return; + const claim = claimAnswerWarnings(dataSource, mode === 'edit' + ? { operation: 'update', resource: objectName, id: recordId } + : { operation: 'create', resource: objectName }); + answerWarningClaim.current = claim; + return () => { + if (answerWarningClaim.current === claim) answerWarningClaim.current = null; + claim.release(); + }; + }, [dataSource, writes, isOpen, mode, objectName, recordId]); + return useCallback(async (saved: unknown) => { - if (dataSource && !submitHandler && (mode === 'create' || mode === 'edit')) { + 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) ?? []; let message = successMessage; if (!message) { // The adapter caches the definition the form body already read. @@ -104,10 +133,10 @@ function useContainerSaveFeedback( mode, }); } - toast.success(message); + toastRecordSave(toast, message, warnings); } await onSuccess?.(saved); - }, [dataSource, submitHandler, mode, successMessage, objectName, onSuccess, objectLabel, t]); + }, [writes, dataSource, mode, successMessage, objectName, onSuccess, objectLabel, t]); } const ObjectFormContainerBody: React.FC = ({ diff --git a/ui/packages/plugin-form/src/__tests__/containerSaveToastCarriesWarnings-28216.test.tsx b/ui/packages/plugin-form/src/__tests__/containerSaveToastCarriesWarnings-28216.test.tsx new file mode 100644 index 00000000000..a97e71f5879 --- /dev/null +++ b/ui/packages/plugin-form/src/__tests__/containerSaveToastCarriesWarnings-28216.test.tsx @@ -0,0 +1,209 @@ +/** + * 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. + */ + +/** + * The `object-form` drawer and modal give a save that answers advisory + * warnings ONE toast, as the record page and the console's record dialogs do + * (objectstack-ai/objectstack#27999): the record-named message on the warning + * variant, its warnings one per line — 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 container's claim removed, + * every "one toast" row RED (the channel toasts, the save toast is the plain + * success one); the CONTROL rows GREEN on both sides. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, waitFor, fireEvent, act, cleanup } from '@testing-library/react'; +import React from 'react'; +import { parkAnswerWarnings } from '@object-ui/react'; + +const { toastSuccess, toastWarning } = vi.hoisted(() => ({ toastSuccess: vi.fn(), toastWarning: vi.fn() })); +vi.mock('@object-ui/components', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, toast: { ...actual.toast, success: toastSuccess, warning: toastWarning, error: vi.fn() } }; +}); + +import { registerAllFields } from '@object-ui/fields'; +import { ObjectFormContainer } from '../ObjectFormContainer'; + +registerAllFields(); + +const TASK = { + name: 'showcase_task', + label: 'Task', + nameField: 'title', + fields: { + title: { type: 'text', label: 'Title' }, + priority: { type: 'text', label: 'Priority' }, + }, +}; + +const STORED = { id: 't1', title: 'Fix login', priority: 'low' }; +const ADVISORY = 'Priority high needs a due date'; + +/** + * An adapter whose writes answer `warnings`, announced the way the adapter's + * write-warning event is: inside the write, before its promise settles. + * `warnFor` names the record the event is about (default: the written one). + */ +function makeDS(channelToast: (message: string) => void, warnFor?: { id: string }) { + 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: warnFor?.id ?? 'n1', warnings: [{ message: ADVISORY }] }; + if (!parkAnswerWarnings(ds, ev, () => channelToast(ADVISORY))) channelToast(ADVISORY); + return { id: 'n1', ...d }; + }), + update: vi.fn(async (o: string, id: string, d: Record) => { + const ev = { operation: 'update' as const, resource: o, id: warnFor?.id ?? id, warnings: [{ message: ADVISORY }] }; + if (!parkAnswerWarnings(ds, ev, () => channelToast(ADVISORY))) channelToast(ADVISORY); + return { ...STORED, ...d }; + }), + }; + return ds; +} + +const input = (name: string) => + waitFor(() => { + const el = document.body.querySelector(`input[name="${name}"]`) as HTMLInputElement | null; + if (!el) throw new Error(`${name} not ready`); + return el; + }); + +async function fillAndSubmit(values: Record) { + for (const [name, value] of Object.entries(values)) { + const el = await input(name); + await act(async () => { + fireEvent.change(el, { target: { value } }); + }); + } + await act(async () => { + fireEvent.submit(document.body.querySelector('form') as HTMLFormElement); + }); +} + +const editSchema = (formType: 'drawer' | 'modal', extra: Record = {}) => + ({ + type: 'object-form', + objectName: 'showcase_task', + mode: 'edit', + recordId: 't1', + formType, + open: true, + ...extra, + }) as any; + +const createSchema = (formType: 'drawer' | 'modal', extra: Record = {}) => + ({ type: 'object-form', objectName: 'showcase_task', mode: 'create', formType, open: true, ...extra }) as any; + +beforeEach(() => { + toastSuccess.mockClear(); + toastWarning.mockClear(); +}); +afterEach(cleanup); + +describe.each(['drawer', 'modal'] as const)('`object-form` `formType: %s` — a save with warnings is one toast', (formType) => { + it('edit: the record-named toast carries the warnings; the channel shows nothing', async () => { + const channelToast = vi.fn(); + const onSuccess = vi.fn(); + const ds = makeDS(channelToast); + render(); + await waitFor(async () => expect((await input('priority')).value).toBe('low')); + await fillAndSubmit({ priority: 'high' }); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + expect(toastWarning).toHaveBeenCalledTimes(1); + expect(toastWarning).toHaveBeenCalledWith('Task "Fix login" updated', { description: ADVISORY }); + expect(toastSuccess).not.toHaveBeenCalled(); + expect(channelToast).not.toHaveBeenCalled(); + }); + + it('create: the record-named toast carries the warnings of its own record', async () => { + const channelToast = vi.fn(); + const onSuccess = vi.fn(); + const ds = makeDS(channelToast); + 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: ADVISORY }); + expect(toastSuccess).not.toHaveBeenCalled(); + expect(channelToast).not.toHaveBeenCalled(); + }); + + it('a declared `successMessage` carries them too', async () => { + const channelToast = vi.fn(); + const onSuccess = vi.fn(); + const ds = makeDS(channelToast); + render(); + await fillAndSubmit({ title: 'Write release notes' }); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + expect(toastWarning).toHaveBeenCalledWith('Queued!', { description: ADVISORY }); + expect(channelToast).not.toHaveBeenCalled(); + }); + + it('CONTROL: warnings about another record keep the channel toast; the save toast is the plain one', async () => { + const channelToast = vi.fn(); + const onSuccess = vi.fn(); + const ds = makeDS(channelToast, { id: 't2' }); + render(); + await waitFor(async () => expect((await input('priority')).value).toBe('low')); + await fillAndSubmit({ priority: 'high' }); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + expect(channelToast).toHaveBeenCalledTimes(1); + expect(toastSuccess).toHaveBeenCalledWith('Task "Fix login" updated'); + expect(toastWarning).not.toHaveBeenCalled(); + }); + + it('CONTROL: a `submitHandler` claims nothing — the container writes nothing to report', async () => { + const channelToast = vi.fn(); + const ds = makeDS(channelToast); + render( + ) => ({ id: 'h1', ...v })) })} + dataSource={ds} + />, + ); + await input('title'); + + await ds.create('showcase_task', { title: 'Written elsewhere' }); + expect(channelToast).toHaveBeenCalledTimes(1); + }); + + it('CONTROL: a closed container claims nothing', async () => { + const channelToast = vi.fn(); + const ds = makeDS(channelToast); + render(); + + await ds.create('showcase_task', { title: 'Written elsewhere' }); + expect(channelToast).toHaveBeenCalledTimes(1); + }); + + it('closing the container shows what its claim still parks', async () => { + const channelToast = vi.fn(); + const ds = makeDS(channelToast); + const { unmount } = render(); + await input('title'); + + // A create of this object the container did not make, while it is open. + await ds.create('showcase_task', { title: 'Written elsewhere' }); + expect(channelToast).not.toHaveBeenCalled(); + unmount(); + expect(channelToast).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/packages/react/src/index.ts b/ui/packages/react/src/index.ts index bad1a9830a6..e9043af974a 100644 --- a/ui/packages/react/src/index.ts +++ b/ui/packages/react/src/index.ts @@ -12,6 +12,9 @@ export * from './hooks/index.js'; // will be empty for now export * from './context/index.js'; // will be empty for now export * from './LazyPluginLoader.js'; export * from './data-invalidation.js'; +// A record save's one toast: the claim the write-warning channel parks a save +// surface's answer warnings on (objectstack-ai/objectstack#28216). +export * from './record-save-feedback.js'; // PageComponentSchema.dataSource — mapping the spec's per-element data binding // onto the schema keys each object-bound block reads (objectstack#6953). export * from './element-data-source/ElementDataSourceGate.js'; diff --git a/ui/packages/react/src/record-save-feedback.test.ts b/ui/packages/react/src/record-save-feedback.test.ts new file mode 100644 index 00000000000..b063263b90f --- /dev/null +++ b/ui/packages/react/src/record-save-feedback.test.ts @@ -0,0 +1,60 @@ +/** + * 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. + */ + +/** + * The record save's one toast, at its own seam: the channel's park door and + * the toast a surface raises. The full chain (adapter → write-warning channel + * → claim) is pinned in `app-shell`'s `writeWarningToast.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { claimAnswerWarnings, parkAnswerWarnings, toastRecordSave } from './record-save-feedback'; + +const ev = (over: Record = {}) => ({ + operation: 'update' as const, + resource: 'quote', + id: 'r1', + warnings: [{ message: 'Discount above 30%' }, { message: 'Discount above 30%' }], + ...over, +}); + +describe('parkAnswerWarnings', () => { + it('parks on the open claim that names the write, and the claim hands back distinct lines', () => { + const adapter = {}; + const claim = claimAnswerWarnings(adapter, { operation: 'update', resource: 'quote', id: 'r1' }); + const show = vi.fn(); + + expect(parkAnswerWarnings(adapter, ev(), show)).toBe(true); + expect(claim.take({ id: 'r1' })).toEqual(['Discount above 30%']); + claim.release(); + expect(show).not.toHaveBeenCalled(); + }); + + it('CONTROL: refuses when no claim names the write — another adapter, record or object', () => { + const adapter = {}; + const claim = claimAnswerWarnings(adapter, { operation: 'update', resource: 'quote', id: 'r1' }); + + expect(parkAnswerWarnings({}, ev(), vi.fn())).toBe(false); + expect(parkAnswerWarnings(adapter, ev({ id: 'r2' }), vi.fn())).toBe(false); + expect(parkAnswerWarnings(adapter, ev({ resource: 'order' }), vi.fn())).toBe(false); + claim.release(); + expect(parkAnswerWarnings(adapter, ev(), vi.fn())).toBe(false); + }); +}); + +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']); + + expect(sink.success).toHaveBeenCalledWith('Quote "Q-1" updated'); + expect(sink.warning).toHaveBeenCalledWith('Quote "Q-1" updated', { description: 'a\nb' }); + expect(sink.success).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/packages/react/src/record-save-feedback.ts b/ui/packages/react/src/record-save-feedback.ts new file mode 100644 index 00000000000..06e1b7e59fd --- /dev/null +++ b/ui/packages/react/src/record-save-feedback.ts @@ -0,0 +1,171 @@ +/** + * 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. + */ + +/** + * record-save-feedback — the ONE toast of a record save + * (objectstack-ai/objectstack#27999). + * + * A write that succeeds with advisory `warnings` reaches the user through two + * doors: the write-warning channel (`app-shell`'s `emitWriteWarning`, fed by + * the adapter's write-warning event) and the save surface's own record-named + * 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}). + * + * 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 + * there is one claim registry and one matching rule for all of them + * (objectstack-ai/objectstack#28216). Like `data-invalidation`, it renders + * nothing: the caller owns the toast sink. + */ + +/** + * The part of a write-warning event the claim reads. `@object-ui/data-objectstack`'s + * `WriteWarningEvent` satisfies it structurally; this package sits below that + * one and cannot import it. + */ +export interface AnswerWarningsEvent { + operation: 'create' | 'update'; + resource: string; + id?: string | number; + warnings?: readonly { message?: string }[]; +} + +/** + * The writes a record save surface reports itself: the record form an open + * overlay or page saves. An update names the record it writes; a create's + * record id is only known from its answer. + */ +export interface AnswerWarningScope { + operation: AnswerWarningsEvent['operation']; + resource: string; + id?: string | number; +} + +/** 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. + */ + take(saved: unknown): string[]; + /** Close the claim; whatever it still parks is shown on the channel's toast. */ + release(): void; +} + +/** The write answer's advisory `warnings`, one entry per distinct message. */ +export function answerWarningLines(ev: AnswerWarningsEvent): string[] { + const messages: string[] = []; + for (const w of ev.warnings ?? []) { + if (w.message && !messages.includes(w.message)) messages.push(w.message); + } + return messages; +} + +interface ParkedAnswerWarnings { + ev: AnswerWarningsEvent; + show: () => void; +} + +interface ClaimEntry { + scope: AnswerWarningScope; + parked: ParkedAnswerWarnings[]; +} + +/** Open claims per adapter instance: the one the write-warning channel subscribes to. */ +const CLAIMS = new WeakMap>(); + +const sameId = (a: unknown, b: unknown): boolean => + a === undefined || a === null ? b === undefined || b === null : String(a) === String(b); + +function matchingClaim(adapter: object, ev: AnswerWarningsEvent): ClaimEntry | undefined { + for (const entry of CLAIMS.get(adapter) ?? []) { + const { scope } = entry; + if (scope.operation !== ev.operation || scope.resource !== ev.resource) continue; + if (scope.operation === 'update' && !sameId(scope.id, ev.id)) continue; + return entry; + } + return undefined; +} + +/** + * The write-warning channel's side: park `ev`'s answer warnings on the open + * claim that names it, with `show` as the channel's own toast for them should + * the claim hand them back. True when a claim took them, so the channel stays + * quiet; false when none names the write, so the channel toasts as it always + * did. + * + * Call it SYNCHRONOUSLY when the event arrives: the adapter emits the event + * inside the write, before the write's promise settles, so the claim holds + * the warnings by the time the surface's success handler runs. + */ +export function parkAnswerWarnings(adapter: object, ev: AnswerWarningsEvent, show: () => void): boolean { + const claim = matchingClaim(adapter, ev); + if (!claim) return false; + claim.parked.push({ ev, show }); + return true; +} + +/** + * Claim the answer warnings of the writes `scope` names on `adapter`, for as + * long as the surface that will report them is open. The surface reads its + * write's warnings with {@link AnswerWarningClaim.take} when the save succeeds + * and closes the claim with {@link AnswerWarningClaim.release} when it closes; + * no parked warning is dropped by either. + */ +export function claimAnswerWarnings(adapter: object, scope: AnswerWarningScope): AnswerWarningClaim { + const entry: ClaimEntry = { scope, parked: [] }; + let open = CLAIMS.get(adapter); + if (!open) CLAIMS.set(adapter, (open = new Set())); + open.add(entry); + const showAll = (list: ParkedAnswerWarnings[]) => { + for (const p of list) p.show(); + }; + return { + take(saved) { + const record = saved && typeof saved === 'object' ? (saved as { id?: unknown; _id?: unknown }) : undefined; + const savedId = record?.id ?? record?._id; + // An update's parked events already name its record; a create's are its + // own only when the answer's record id is the saved record's. + const own = entry.parked.filter((p) => scope.operation === 'update' || sameId(p.ev.id, savedId)); + const others = entry.parked.filter((p) => !own.includes(p)); + entry.parked = []; + showAll(others); + const messages: string[] = []; + for (const p of own) { + for (const m of answerWarningLines(p.ev)) if (!messages.includes(m)) messages.push(m); + } + return messages; + }, + release() { + open?.delete(entry); + const left = entry.parked; + entry.parked = []; + showAll(left); + }, + }; +} + +/** Where a record save toast goes. Structurally satisfied by sonner's `toast`. */ +export interface RecordSaveSink { + success(title: 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. + */ +export function toastRecordSave(sink: RecordSaveSink, message: string, warnings: readonly string[] = []): void { + if (warnings.length > 0) sink.warning(message, { description: warnings.join('\n') }); + else sink.success(message); +}