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
15 changes: 15 additions & 0 deletions .changeset/28216-form-container-one-save-toast.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 1 addition & 2 deletions ui/packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
134 changes: 12 additions & 122 deletions ui/packages/app-shell/src/providers/writeWarningToast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -257,19 +261,10 @@ export async function emitWriteWarning(
): Promise<void> {
// 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);
}

/**
Expand All @@ -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<object, Set<ClaimEntry>>();

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,
Expand Down
2 changes: 1 addition & 1 deletion ui/packages/app-shell/src/views/RecordFormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
39 changes: 34 additions & 5 deletions ui/packages/plugin-form/src/ObjectFormContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -83,16 +88,40 @@ 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<typeof usePreparedFormSchema>['schema'],
dataSource: ObjectFormComponentProps['dataSource'],
): (saved: unknown) => Promise<void> {
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<AnswerWarningClaim | null>(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.
Expand All @@ -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<ObjectFormComponentProps> = ({
Expand Down
Loading
Loading