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
13 changes: 13 additions & 0 deletions .changeset/submithandler-variant-forms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
---
"@object-ui/plugin-form": patch
---

Honour the declared `submitHandler` seam in every form variant, not just the simple one.

`ObjectFormSchema.submitHandler` is documented as the seam a host uses to own persistence: the form validates and hands the collected values over instead of calling `dataSource.create` / `dataSource.update`. `ObjectForm` forwarded the key into every variant it routes to, but only `SimpleObjectForm` read it — `TabbedForm`, `WizardForm`, `SplitForm`, `DrawerForm` and `ModalForm` persisted directly.

**Behaviour change on a persistence path.** A master-detail parent half rendered `tabbed` (or `split`) now commits through the atomic `batchTransaction` together with its child collections, instead of writing the parent independently through `dataSource.create`. Previously the child leg was never attempted on those layouts: the parent was committed alone, the entered line items were silently discarded, no compensation ran, and a success toast confirmed the save. A failing child leg now leaves no committed parent, on every layout that renders the parent half inline.

`WizardForm` additionally skips its own default success toast / redirect arms when a `submitHandler` is present, matching `ObjectForm`, so a host that owns the write also owns the outcome.

The `object-master-detail-form.formType` vocabulary is unchanged and stays `simple | tabbed`.
44 changes: 35 additions & 9 deletions packages/plugin-form/src/DrawerForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@
*/

import React, { useState, useCallback, useEffect, useMemo, useRef, useId } from 'react';
import type { FormField, DataSource } from '@object-ui/types';
import type { FormField, DataSource, ObjectFormSchema } from '@object-ui/types';
import {
Sheet,
SheetContent,
Expand DownExpand Up@@ -156,6 +156,21 @@ export interface DrawerFormSchema {
readOnly?: boolean;
layout?: 'vertical' | 'horizontal';
columns?: number;
/**
* Override persistence — the seam a host uses to own the write. Declared as
* `ObjectFormSchema['submitHandler']` rather than restated, so this variant
* and the canonical key `ObjectForm` forwards can never drift apart.
* When supplied, the form validates and hands the collected values
* to this handler INSTEAD of calling `dataSource.create` /
* `dataSource.update`; the returned record is passed on to `onSuccess`.
*
* `MasterDetailForm` supplies it to route the parent AND its child
* collections through one atomic `batchTransaction` (#2679 / ADR-0034
* item 4). A renderer that does not read it writes the parent on its own and
* escapes that transaction — objectui#6176.
*/
submitHandler?: ObjectFormSchema['submitHandler'];

onSuccess?: (data: any) => void | Promise<void>;
onError?: (error: Error) => void;
onCancel?: () => void;
Expand DownExpand Up@@ -394,14 +409,25 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({

let result;
const payload = sanitizeFormData(data, objectSchema);
if (schema.mode === 'create') {
// Omit the fields the producer owns (#4069) — see
// `omitServerResolvedDefaults` for why an empty key is not the same as
// no key at insert time.
result = await dataSource.create(
schema.objectName,
omitServerResolvedDefaults(payload, objectSchema),
);
// Omit the fields the producer owns (#4069) — see
// `omitServerResolvedDefaults` for why an empty key is not the same as
// no key at insert time. Create only: on an edit form a cleared column is
// a real removal. Computed ONCE so every persistence route below — the
// host-owned seam included — writes the identical payload.
const writePayload = schema.mode === 'create'
? omitServerResolvedDefaults(payload, objectSchema)
: payload;

if (schema.submitHandler) {
// The host owns persistence (e.g. MasterDetailForm batching the parent
// + its child collections into ONE atomic transaction). The form
// validates and hands the values over; it does NOT create/update
// itself. Same seam and same precedence as SimpleObjectForm — every
// renderer `ObjectForm` routes to must check it FIRST, or a declared
// host-owned write silently becomes an independent one (objectui#6176).
result = await schema.submitHandler(writePayload);
} else if (schema.mode === 'create') {
result = await dataSource.create(schema.objectName, writePayload);
} else if (schema.mode === 'edit' && schema.recordId) {
// OCC-guarded: sends `ifMatch` from the record we read; a 409 asks the
// user to keep editing (drawer stays open, draft intact) or overwrite.
Expand Down
45 changes: 36 additions & 9 deletions packages/plugin-form/src/ModalForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@
*/

import React, { useState, useCallback, useEffect, useMemo, useId, useRef } from 'react';
import type { FormField, DataSource } from '@object-ui/types';
import type { FormField, DataSource, ObjectFormSchema } from '@object-ui/types';
import {
Dialog,
MobileDialogContent,
Expand DownExpand Up@@ -152,6 +152,21 @@ export interface ModalFormSchema {
readOnly?: boolean;
layout?: 'vertical' | 'horizontal';
columns?: number;
/**
* Override persistence — the seam a host uses to own the write. Declared as
* `ObjectFormSchema['submitHandler']` rather than restated, so this variant
* and the canonical key `ObjectForm` forwards can never drift apart.
* When supplied, the form validates and hands the collected values
* to this handler INSTEAD of calling `dataSource.create` /
* `dataSource.update`; the returned record is passed on to `onSuccess`.
*
* `MasterDetailForm` supplies it to route the parent AND its child
* collections through one atomic `batchTransaction` (#2679 / ADR-0034
* item 4). A renderer that does not read it writes the parent on its own and
* escapes that transaction — objectui#6176.
*/
submitHandler?: ObjectFormSchema['submitHandler'];

onSuccess?: (data: any) => void | Promise<void>;
onError?: (error: Error) => void;
onCancel?: () => void;
Expand DownExpand Up@@ -446,14 +461,26 @@ export const ModalForm: React.FC<ModalFormProps> = ({
}
payload = stripped;
}
if (schema.mode === 'create') {
// Omit the fields the producer owns (#4069) — see
// `omitServerResolvedDefaults` for why an empty key is not the same as
// no key at insert time.
result = await dataSource.create(
schema.objectName,
omitServerResolvedDefaults(payload, objectSchema),
);
// Omit the fields the producer owns (#4069) — see
// `omitServerResolvedDefaults` for why an empty key is not the same as
// no key at insert time. Create only: on an edit form a cleared column is
// a real removal. Computed ONCE (after the FLS strip above) so every
// persistence route below — the host-owned seam included — writes the
// identical payload.
const writePayload = schema.mode === 'create'
? omitServerResolvedDefaults(payload, objectSchema)
: payload;

if (schema.submitHandler) {
// The host owns persistence (e.g. MasterDetailForm batching the parent
// + its child collections into ONE atomic transaction). The form
// validates and hands the values over; it does NOT create/update
// itself. Same seam and same precedence as SimpleObjectForm — every
// renderer `ObjectForm` routes to must check it FIRST, or a declared
// host-owned write silently becomes an independent one (objectui#6176).
result = await schema.submitHandler(writePayload);
} else if (schema.mode === 'create') {
result = await dataSource.create(schema.objectName, writePayload);
} else if (schema.mode === 'edit' && schema.recordId) {
// OCC-guarded: sends `ifMatch` from the record we read; a 409 asks the
// user to keep editing (modal stays open, draft intact) or overwrite.
Expand Down
46 changes: 36 additions & 10 deletions packages/plugin-form/src/SplitForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@
*/

import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import type { FormField, DataSource } from '@object-ui/types';
import type { FormField, DataSource, ObjectFormSchema } from '@object-ui/types';
import { cn } from '@object-ui/components';
import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react';
import { buildSectionFields as buildSectionFieldsShared } from './sectionFields';
Expand DownExpand Up@@ -105,6 +105,21 @@ export interface SplitFormSchema {
initialValues?: Record<string, any>;
initialData?: Record<string, any>;
readOnly?: boolean;
/**
* Override persistence — the seam a host uses to own the write. Declared as
* `ObjectFormSchema['submitHandler']` rather than restated, so this variant
* and the canonical key `ObjectForm` forwards can never drift apart.
* When supplied, the form validates and hands the collected values
* to this handler INSTEAD of calling `dataSource.create` /
* `dataSource.update`; the returned record is passed on to `onSuccess`.
*
* `MasterDetailForm` supplies it to route the parent AND its child
* collections through one atomic `batchTransaction` (#2679 / ADR-0034
* item 4). A renderer that does not read it writes the parent on its own and
* escapes that transaction — objectui#6176.
*/
submitHandler?: ObjectFormSchema['submitHandler'];

onSuccess?: (data: any) => void | Promise<void>;
onError?: (error: Error) => void;
onCancel?: () => void;
Expand DownExpand Up@@ -232,22 +247,33 @@ export const SplitForm: React.FC<SplitFormProps> = ({

try {
let result;
if (schema.mode === 'create') {
// Omit the fields the producer owns (#4069) — see
// `omitServerResolvedDefaults` for why an empty key is not the same as
// no key at insert time.
result = await dataSource.create(
schema.objectName,
omitServerResolvedDefaults(data, objectSchema),
);
// Omit the fields the producer owns (#4069) — see
// `omitServerResolvedDefaults` for why an empty key is not the same as
// no key at insert time. Create only: on an edit form a cleared column is
// a real removal. Computed ONCE so every persistence route below — the
// host-owned seam included — writes the identical payload.
const writePayload = schema.mode === 'create'
? omitServerResolvedDefaults(data, objectSchema)
: data;

if (schema.submitHandler) {
// The host owns persistence (e.g. MasterDetailForm batching the parent
// + its child collections into ONE atomic transaction). The form
// validates and hands the values over; it does NOT create/update
// itself. Same seam and same precedence as SimpleObjectForm — every
// renderer `ObjectForm` routes to must check it FIRST, or a declared
// host-owned write silently becomes an independent one (objectui#6176).
result = await schema.submitHandler(writePayload);
} else if (schema.mode === 'create') {
result = await dataSource.create(schema.objectName, writePayload);
} else if (schema.mode === 'edit' && schema.recordId) {
// OCC-guarded: sends `ifMatch` from the record we read; a 409 asks the
// user to keep editing (skip the success path) or overwrite.
const outcome = await saveWithOcc({
dataSource,
objectName: schema.objectName,
recordId: schema.recordId,
payload: data,
payload: writePayload,
baseRecord: formData,
});
if (outcome.status === 'cancelled') return;
Expand Down
46 changes: 36 additions & 10 deletions packages/plugin-form/src/TabbedForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@
*/

import React, { useState, useCallback, useRef } from 'react';
import type { FormField, DataSource } from '@object-ui/types';
import type { FormField, DataSource, ObjectFormSchema } from '@object-ui/types';
import { cn } from '@object-ui/components';
import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react';
import { buildSectionFields as buildSectionFieldsShared } from './sectionFields';
Expand DownExpand Up@@ -145,6 +145,21 @@ export interface TabbedFormSchema {
*/
readOnly?: boolean;

/**
* Override persistence — the seam a host uses to own the write. Declared as
* `ObjectFormSchema['submitHandler']` rather than restated, so this variant
* and the canonical key `ObjectForm` forwards can never drift apart.
* When supplied, the form validates and hands the collected values
* to this handler INSTEAD of calling `dataSource.create` /
* `dataSource.update`; the returned record is passed on to `onSuccess`.
*
* `MasterDetailForm` supplies it to route the parent AND its child
* collections through one atomic `batchTransaction` (#2679 / ADR-0034
* item 4). A renderer that does not read it writes the parent on its own and
* escapes that transaction — objectui#6176.
*/
submitHandler?: ObjectFormSchema['submitHandler'];

/**
* Callbacks
*/
Expand DownExpand Up@@ -302,22 +317,33 @@ export const TabbedForm: React.FC<TabbedFormProps> = ({
try {
let result;

if (schema.mode === 'create') {
// Omit the fields the producer owns (#4069) — see
// `omitServerResolvedDefaults` for why an empty key is not the same as
// no key at insert time.
result = await dataSource.create(
schema.objectName,
omitServerResolvedDefaults(data, objectSchema),
);
// Omit the fields the producer owns (#4069) — see
// `omitServerResolvedDefaults` for why an empty key is not the same as
// no key at insert time. Create only: on an edit form a cleared column is
// a real removal. Computed ONCE so every persistence route below — the
// host-owned seam included — writes the identical payload.
const writePayload = schema.mode === 'create'
? omitServerResolvedDefaults(data, objectSchema)
: data;

if (schema.submitHandler) {
// The host owns persistence (e.g. MasterDetailForm batching the parent
// + its child collections into ONE atomic transaction). The form
// validates and hands the values over; it does NOT create/update
// itself. Same seam and same precedence as SimpleObjectForm — every
// renderer `ObjectForm` routes to must check it FIRST, or a declared
// host-owned write silently becomes an independent one (objectui#6176).
result = await schema.submitHandler(writePayload);
} else if (schema.mode === 'create') {
result = await dataSource.create(schema.objectName, writePayload);
} else if (schema.mode === 'edit' && schema.recordId) {
// OCC-guarded: sends `ifMatch` from the record we read; a 409 asks the
// user to keep editing (skip the success path) or overwrite.
const outcome = await saveWithOcc({
dataSource,
objectName: schema.objectName,
recordId: schema.recordId,
payload: data,
payload: writePayload,
baseRecord: formData,
});
if (outcome.status === 'cancelled') return;
Expand Down
55 changes: 43 additions & 12 deletions packages/plugin-form/src/WizardForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@
*/

import React, { useState, useCallback, useMemo } from 'react';
import type { FormField, DataSource } from '@object-ui/types';
import type { FormField, DataSource, ObjectFormSchema } from '@object-ui/types';
import { Button, cn, toast } from '@object-ui/components';
import { AlertCircle, Check, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
import { resolveFieldRuleState, evalFieldPredicate, isMissingForRequired, isServerOwnedValue } from '@object-ui/core';
Expand DownExpand Up@@ -203,6 +203,21 @@ export interface WizardFormSchema {
*/
readOnly?: boolean;

/**
* Override persistence — the seam a host uses to own the write. Declared as
* `ObjectFormSchema['submitHandler']` rather than restated, so this variant
* and the canonical key `ObjectForm` forwards can never drift apart.
* When supplied, the form validates and hands the collected values
* to this handler INSTEAD of calling `dataSource.create` /
* `dataSource.update`; the returned record is passed on to `onSuccess`.
*
* `MasterDetailForm` supplies it to route the parent AND its child
* collections through one atomic `batchTransaction` (#2679 / ADR-0034
* item 4). A renderer that does not read it writes the parent on its own and
* escapes that transaction — objectui#6176.
*/
submitHandler?: ObjectFormSchema['submitHandler'];

/**
* Callbacks
*/
Expand DownExpand Up@@ -550,14 +565,26 @@ export const WizardForm: React.FC<WizardFormProps> = ({
}

let result;
if (schema.mode === 'create') {
// Omit the fields the producer owns (#4069) — see
// `omitServerResolvedDefaults` for why an empty key is not the same
// as no key at insert time.
result = await dataSource.create(
schema.objectName,
omitServerResolvedDefaults(mergedData, objectSchema),
);
// Omit the fields the producer owns (#4069) — see
// `omitServerResolvedDefaults` for why an empty key is not the same
// as no key at insert time. Create only: on an edit form a cleared
// column is a real removal. Computed ONCE so every persistence route
// below — the host-owned seam included — writes the identical payload.
const writePayload = schema.mode === 'create'
? omitServerResolvedDefaults(mergedData, objectSchema)
: mergedData;

if (schema.submitHandler) {
// The host owns persistence (e.g. MasterDetailForm batching the
// parent + its child collections into ONE atomic transaction). The
// form validates and hands the values over; it does NOT create/update
// itself. Same seam and same precedence as SimpleObjectForm — every
// renderer `ObjectForm` routes to must check it FIRST, or a declared
// host-owned write silently becomes an independent one
// (objectui#6176).
result = await schema.submitHandler(writePayload);
} else if (schema.mode === 'create') {
result = await dataSource.create(schema.objectName, writePayload);
} else if (schema.mode === 'edit' && schema.recordId) {
// OCC-guarded: sends `ifMatch` from the record we read; a 409 asks
// the user to keep editing (skip the success path) or overwrite.
Expand All@@ -567,7 +594,7 @@ export const WizardForm: React.FC<WizardFormProps> = ({
dataSource,
objectName: schema.objectName,
recordId: schema.recordId,
payload: mergedData,
payload: writePayload,
baseRecord: formData,
});
if (outcome.status === 'cancelled') return;
Expand All@@ -576,7 +603,7 @@ export const WizardForm: React.FC<WizardFormProps> = ({

if (schema.onSuccess) {
await schema.onSuccess(result);
} else if (schema.submitBehavior) {
} else if (!schema.submitHandler && schema.submitBehavior) {
const behavior = schema.submitBehavior;
switch (behavior.kind) {
case 'redirect': {
Expand DownExpand Up@@ -636,8 +663,12 @@ export const WizardForm: React.FC<WizardFormProps> = ({
break;
}
}
} else {
} else if (!schema.submitHandler) {
// Legacy declarative success behaviors for metadata-only wizards.
// Skipped when a `submitHandler` owns persistence: the host already
// reports the outcome, so a second toast/redirect here would
// double-confirm (the guard SimpleObjectForm applies for the same
// reason).
const nav = resolveSuccessNavigate(schema.navigateOnSuccess, result);
if (nav) {
// Landing on the saved record is the confirmation — no toast needed.
Expand Down
Loading
Loading