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
9 changes: 9 additions & 0 deletions .changeset/27453-container-save-toast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@object-ui/i18n': patch
'@object-ui/plugin-form': patch
---

Clause-②: yes (widening: @object-ui/i18n gains the public export recordSaveMessage; no accept set moves)
Metadata-protocol: none

A record saved from an `object-form` block's drawer or modal (`formType: 'drawer' | 'modal'`, including `<ObjectForm formType="drawer">` on a React page) now gets the record-named success toast the console's record dialogs give, such as `Task "Fix login" updated` or `Task "Write release notes" created`. A record with nothing nameable keeps the object-label toast, and a declared `successMessage` is used as written. The toast comes before the author's `onSuccess`, so a page whose `onSuccess` only closes the overlay and reloads its list needs no change. A host that mounts `DrawerForm` / `ModalForm` itself, and a form with a `submitHandler`, keeps owning its feedback and is not toasted twice. `@object-ui/i18n` adds `recordSaveMessage(t, { label, objectDef, saved, mode })`, the one builder of that message, which the console's record dialog and record page now share.
34 changes: 10 additions & 24 deletions ui/packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ import { Empty, EmptyTitle, EmptyDescription, Button, DataErrorState } from '@ob
import { toast } from 'sonner';
import { useActionRunner, useGlobalUndo, useMutationInvalidationBridge, notifyDataChanged, FilterScopeProvider, resolveApprovalLock } from '@object-ui/react';
import { useRecordApprovals, openApprovalOf } from '../hooks/useRecordApprovals.js';
import { getRecordDisplayName } from '@object-ui/core';
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
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';
Expand Down Expand Up @@ -881,28 +880,15 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =

// objectstack-ai/objectstack#26172 — a record save names the record it wrote
// (`ux-conventions.write-feedback`): `Task "Quarterly review" created`. The
// name is the saved record's display name from the ONE ADR-0079 resolver,
// read through the object being saved. The resolver's floor (`Record #<id>`,
// or the empty fallback for an id-less row) is not a name, so a record with
// nothing nameable keeps the object-label toast it always had.
const recordSaveMessage = useCallback((saved: any): string => {
const label = formObjectDef ? objectLabel(formObjectDef as any) : t('common.record', { defaultValue: 'Record' });
const resolved = saved && typeof saved === 'object'
? getRecordDisplayName(formObjectDef, saved, { fallback: '' })
: '';
const id = saved?.id ?? saved?._id;
const title = resolved && !(id !== null && id !== undefined && resolved === `Record #${id}`)
? resolved
: undefined;
if (editingRecord) {
return title
? t('form.updatedNamed', { object: label, title, defaultValue: `${label} "${title}" updated` })
: t('form.updateSuccess', { object: label, defaultValue: `${label} updated successfully` });
}
return title
? t('form.createdNamed', { object: label, title, defaultValue: `${label} "${title}" created` })
: t('form.createSuccess', { object: label, defaultValue: `${label} created successfully` });
}, [editingRecord, formObjectDef, objectLabel, t]);
// message is the ONE builder every record save surface shares
// (`recordSaveMessage` in `@object-ui/i18n`), read through the object being
// saved.
const recordSaveMessage = useCallback((saved: any): string => buildRecordSaveMessage(t, {
label: formObjectDef ? objectLabel(formObjectDef as any) : t('common.record', { defaultValue: 'Record' }),
objectDef: formObjectDef,
saved,
mode: editingRecord ? 'edit' : 'create',
}), [editingRecord, formObjectDef, objectLabel, t]);

// objectstack-ai/objectstack#27999 — the open record form reports its own
// write's advisory warnings: while the overlay is open, the write-warning
Expand Down
39 changes: 12 additions & 27 deletions ui/packages/app-shell/src/views/RecordFormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@ import { ObjectForm } from '@object-ui/plugin-form';
import { Button, Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
import { ArrowLeft, Building2, Database } from 'lucide-react';
import { toast } from 'sonner';
import { getRecordDisplayName } from '@object-ui/core';
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
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';
Expand Down Expand Up @@ -166,15 +165,11 @@ export function RecordFormPage({ mode }: RecordFormPageProps) {
: t('form.editTitle', { object: label });

// objectstack-ai/objectstack#26461 — the save toast names the record it
// wrote (`ux-conventions.write-feedback`), as the dialog paths in
// `console/AppContent.tsx` (`recordSaveMessage`) have since
// objectstack-ai/objectstack#26172: `Task "Quarterly review" created`. Same
// resolver, same floor, same keys. The name is the saved record's display
// name from the ONE ADR-0079 resolver, read through this page's object;
// `ObjectForm` hands the write's result to `onSuccess`. The resolver's floor
// (`Record #<id>`, or the empty fallback for an id-less row) is not a name,
// so a record with nothing nameable keeps the object-label toast. The two
// named keys are in all ten packs, so they pass bare like every key above.
// wrote (`ux-conventions.write-feedback`): `Task "Quarterly review" created`.
// The message is the ONE builder every record save surface shares
// (`recordSaveMessage` in `@object-ui/i18n`), read through this page's
// object; `ObjectForm` hands the write's result to `onSuccess`. Its keys are
// in all ten packs, so they pass bare like every key above.
//
// objectstack-ai/objectstack#27999 — and it is the save's ONE toast: while
// this page is open, the write-warning channel parks the answer warnings of
Expand All @@ -197,22 +192,12 @@ export function RecordFormPage({ mode }: RecordFormPageProps) {

const handleSuccess = useCallback((saved?: any) => {
const warnings = answerWarningClaim.current?.take(saved) ?? [];
const resolved = saved && typeof saved === 'object'
? getRecordDisplayName(objectDef, saved, { fallback: '' })
: '';
const id = saved?.id ?? saved?._id;
const title = resolved && !(id !== null && id !== undefined && resolved === `Record #${id}`)
? resolved
: undefined;
if (mode === 'create') {
toastRecordSave(toast, title
? t('form.createdNamed', { object: label, title })
: t('form.createSuccess', { object: label }), warnings);
} else {
toastRecordSave(toast, title
? t('form.updatedNamed', { object: label, title })
: t('form.updateSuccess', { object: label }), warnings);
}
toastRecordSave(toast, recordSaveMessage(t, {
label,
objectDef,
saved,
mode,
}), warnings);
goBack();
}, [mode, t, label, objectDef, goBack]);

Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ export type { TranslateFn } from './translateFn.js';
// Convention-based object/field label i18n
export { useObjectLabel, useSafeFieldLabel } from './useObjectLabel.js';

// The record-named save message every record save surface toasts.
export { recordSaveMessage, type RecordSaveMessageInput } from './recordSaveMessage.js';

// Locale packs — ⛔ ONE catalogue is statically re-exported here, not ten
// (objectui#7479).
//
Expand Down
63 changes: 63 additions & 0 deletions ui/packages/i18n/src/recordSaveMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* 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 message of a record save (`ux-conventions.write-feedback`): the save
* names the record it wrote — `Task "Quarterly review" created`.
*
* ONE builder for every save surface: the console's record dialogs, the
* record form page and the `object-form` block's drawer and modal containers.
* It lives here, below both `@object-ui/app-shell` and
* `@object-ui/plugin-form`, because its four keys live in this package's packs
* and both of those packages already depend on it.
*
* @module recordSaveMessage
*/

import { getRecordDisplayName } from '@object-ui/core';
import type { TranslateFn } from './translateFn.js';

export interface RecordSaveMessageInput {
/** The singular label of the saved record's object, already translated. */
label: string;
/** The definition of the object being saved; may be absent or partial. */
objectDef: unknown;
/** The record the write answered with. */
saved: unknown;
/** `create` for an insert, `edit` for an update. */
mode: 'create' | 'edit';
}

/**
* The record-named save message, or the object-label message when the record
* has nothing nameable.
*
* The name is the saved record's display name from the ONE ADR-0079 resolver,
* read through the object being saved. The resolver's floor (`Record #<id>`,
* or the empty fallback for an id-less row) is not a name, so such a record
* keeps the object-label message.
*/
export function recordSaveMessage(t: TranslateFn, input: RecordSaveMessageInput): string {
const { label, objectDef, saved, mode } = input;
const resolved = saved && typeof saved === 'object'
? getRecordDisplayName(objectDef, saved, { fallback: '' })
: '';
const record = saved && typeof saved === 'object' ? (saved as { id?: unknown; _id?: unknown }) : undefined;
const id = record?.id ?? record?._id;
const title = resolved && !(id !== null && id !== undefined && resolved === `Record #${id}`)
? resolved
: undefined;
if (mode === 'create') {
return title
? t('form.createdNamed', { object: label, title })
: t('form.createSuccess', { object: label });
}
return title
? t('form.updatedNamed', { object: label, title })
: t('form.updateSuccess', { object: label });
}
61 changes: 60 additions & 1 deletion ui/packages/plugin-form/src/ObjectFormContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,19 @@
* Both maps prepare the schema through `usePreparedFormSchema`, the one
* preparation `ObjectForm` uses, so the labels, field-level security and
* `{ group }` sections a container draws are the ones the layouts draw.
*
* As the caller, the block also gives the save its feedback
* (`ux-conventions.write-feedback`): a write the container makes toasts the
* record-named message the console's record dialogs and record page toast
* (`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.
*/

import React from 'react';
import React, { useCallback } from 'react';
import type { ObjectFormSchema } from '@object-ui/types';
import { toast } from '@object-ui/components';
import { createSafeTranslation, recordSaveMessage, useObjectLabel } from '@object-ui/i18n';
import { usePreparedFormSchema, ObjectForm, type ObjectFormComponentProps } from './ObjectForm';
import { DrawerForm } from './DrawerForm';
import { ModalForm } from './ModalForm';
Expand All @@ -54,11 +63,59 @@ export const ObjectFormContainer: React.FC<ObjectFormComponentProps> = ({ schema
</RecordFieldAccessForForm>
);

/**
* The save message's copy. Falls back to the `en` pack's values when no i18n
* provider is mounted, so a provider-less host toasts English, not raw keys.
*/
const useSaveFeedbackTranslation = createSafeTranslation(
{
'form.createdNamed': '{{object}} "{{title}}" created',
'form.updatedNamed': '{{object}} "{{title}}" updated',
'form.createSuccess': '{{object}} created successfully',
'form.updateSuccess': '{{object}} updated successfully',
'common.record': 'Record',
},
'form.createdNamed',
);

/**
* The container's `onSuccess`: toast the save, then hand the record to the
* 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).
*/
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;
return useCallback(async (saved: unknown) => {
if (dataSource && !submitHandler && (mode === 'create' || mode === 'edit')) {
let message = successMessage;
if (!message) {
// The adapter caches the definition the form body already read.
const objectDef = await Promise.resolve(dataSource.getObjectSchema?.(objectName)).catch(() => undefined);
message = recordSaveMessage(t, {
label: objectDef?.name ? objectLabel(objectDef) : t('common.record'),
objectDef,
saved,
mode,
});
}
toast.success(message);
}
await onSuccess?.(saved);
}, [dataSource, submitHandler, mode, successMessage, objectName, onSuccess, objectLabel, t]);
}

const ObjectFormContainerBody: React.FC<ObjectFormComponentProps> = ({
schema: rawSchema,
dataSource,
}) => {
const { schema, tSec } = usePreparedFormSchema(rawSchema, dataSource);
const handleSuccess = useContainerSaveFeedback(schema, dataSource);

if (schema.formType === 'drawer') {
// `layout` passes through unfolded: it is `vertical | horizontal` on both
Expand Down Expand Up @@ -88,6 +145,7 @@ const ObjectFormContainerBody: React.FC<ObjectFormComponentProps> = ({
})),
open: schema.open,
onOpenChange: schema.onOpenChange,
onSuccess: handleSuccess,
drawerSide: schema.drawerSide,
drawerWidth: schema.drawerWidth,
}}
Expand Down Expand Up @@ -126,6 +184,7 @@ const ObjectFormContainerBody: React.FC<ObjectFormComponentProps> = ({
})),
open: schema.open,
onOpenChange: schema.onOpenChange,
onSuccess: handleSuccess,
modalSize: schema.modalSize,
modalCloseButton: schema.modalCloseButton,
}}
Expand Down
Loading
Loading