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
2 changes: 1 addition & 1 deletion .changeset/10120-form-omits-fls-denied-fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The verdict that answers 「may this caller edit this field」 already existed a

**What changed, in observable terms.**

- The field-level verdict now arrives at the ONE outbound filter as a predicate (`sanitizeFormData`'s `canEdit`), instead of as a strip loop written out after each container's call. ⚠️ `DrawerForm` previously sent every displayed field regardless of the caller's field permissions; it no longer does. `ObjectForm` and `ModalForm` produce the same payloads they did before — their loops were correct, they were just copies.
- The field-level verdict now arrives at the ONE outbound filter as a predicate (`sanitizeFormData`'s `canEdit`), instead of as a strip loop written out after each container's call. ⚠️ `DrawerForm` previously sent every displayed field regardless of the caller's field permissions; it no longer does. `ObjectForm` and `ModalForm` already withheld the refused field, and still do — their loops were correct, they were just copies.
- The render pass is likewise one function for all three containers. ⚠️ `DrawerForm` previously drew a field the caller may read but not edit as a live input; it now draws it read-only and disabled, exactly as the other two already did. A field the caller may not READ is dropped, also as before.
- Both halves stay fail-open with no `PermissionProvider` / `MePermissionsProvider` mounted, unchanged: a standalone form, a designer preview and a guest surface have no resolvable principal, and the server still enforces.
- A lookup's selected chip no longer offers its remove ✕ when the field is disabled. ⚠️ This is how BOTH refusals reach the widget — a field the object declares `readonly` is folded into `disabled` by the form's section builder, and a field the permission set refuses is marked disabled by the pass above — so a reporter could previously clear a master-detail parent the server would then refuse to unset. The trigger and the browse button were already disabled; the chip's ✕ was the one control the gate had missed. The chips themselves stay: the value is readable, only the affordance goes.
Expand Down
23 changes: 23 additions & 0 deletions .changeset/10156-edit-form-writes-only-changed-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@object-ui/plugin-form': patch
'@object-ui/types': patch
---

An edit form now writes only the fields that changed (objectui#10156).

**Clause-②: no.** No exported symbol, type or prop changes. `@object-ui/plugin-form` publishes `.` only, from `index.tsx`, and `index.tsx` re-exports neither `sanitize` nor `masterDetailTx`. After a build, `dist/index.d.ts` names none of the new helpers. The change is in what the client sends, and ⚠️ in what a host `submitHandler` receives in edit mode (see below).

**Before.** A master-detail child row already sent only the cells that differed from its loaded snapshot (objectui#10108). The other two edit payloads did not. The plain record edit `PATCH` and the parent operation of a master-detail batch sent every sanitized field on every save, including fields the user never touched. With the concurrency guard, a `409` followed by **Overwrite** therefore rewrote every field, not only the ones this user changed.

**What changed, in observable terms.**

- In edit mode, `ObjectForm`, `ModalForm` and `DrawerForm` compare their save with the record they read through `findOne`, and write only the fields that differ. The parent operation of a master-detail batch follows, because its header is a simple `ObjectForm`.
- There is one comparison, shared with the master-detail child rows. It sends anything it cannot prove unchanged. `null` and `undefined` count as the same value. `null` and `''` are different. So are `5` and `'5'`, a lookup id and its expanded object, a `Date` and a date string, and two objects whose keys come in a different order. A field the form changed by itself after the read, such as a cascade clear, is sent.
- A save with nothing changed still sends the full sanitized payload. It stays a real request, with the same concurrency guard and a real server record for `onSuccess`.
- A form that did not read the record itself still sends every field. That covers a create, a record supplied as `initialData`, and inline `customFields`.
- After a successful save, the form counts the fields it just wrote as saved. A form that stays open compares its next save with the record as it is now. Changing a field back to its first-read value is therefore still sent.
- The concurrency guard is unchanged. The update still carries `ifMatch` = the `updated_at` the form read, and a `409` still offers **Keep editing** or **Overwrite**. **Overwrite** now resends only the changed fields.
- ⚠️ A host `submitHandler` on an edit form receives the payload the form would have written. That is the changed fields, or the full sanitized payload when nothing changed. A host that needs the whole record must read it itself. In this repository, only `MasterDetailForm` passes a `submitHandler` to an edit form. Its header form receives the changed fields. Its row editor has no `recordId`, so it still receives every value.
- The JSDoc of `ObjectFormSchema.submitHandler` in `@object-ui/types`, and its copies on `ModalFormSchema` and `DrawerFormSchema`, now say what an edit-mode handler receives.

**Not covered.** The `tabbed`, `wizard` and `split` variants have save paths of their own and still send every value they hold. That includes a master-detail header laid out `tabbed`, and a simple form whose mobile `stepper` option shows it one step at a time through the wizard.
14 changes: 14 additions & 0 deletions content/docs/plugins/plugin-form.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,20 @@ The components are on the package's export surface — `ObjectForm`,
(`TabbedFormSchema`, `WizardFormSchema`, `ModalFormSchema`, …). There is no
aggregate map among them.

## What an edit save writes

An `object-form` in `mode: 'edit'` reads its record with `findOne`, and its save
writes only the fields that differ from that read. That covers the simple form,
the `modal` and `drawer` variants, and the parent operation of a master-detail
form. The `tabbed`, `wizard` and `split` variants still send every value the form
holds, and so does a simple form whose mobile `stepper` option routes it through
the wizard. A field whose sameness cannot be proven is sent: `5` and `'5'`, `null` and
`''`, and a lookup id and its expanded object all count as different. A save with
nothing changed still sends the full payload, as it always has. The `ifMatch`
concurrency guard is unchanged, and its **Overwrite** choice now resends only the
changed fields. A host `submitHandler` receives the same payload in edit mode.
The package README has the full rule, under "What an edit save writes".

## Examples

### Form with Validation
Expand Down
51 changes: 51 additions & 0 deletions packages/plugin-form/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,57 @@ returning, which reported a save that never happened and, through
(objectui#6300). A declared `submitHandler` is consulted first, so a host that
said it owns the write is never bypassed for want of an adapter it never needed.

## What an edit save writes

An `object-form` in `mode: 'edit'` with a `recordId` reads the record with
`dataSource.findOne`, and its save writes **only the fields that differ from
that read** (objectui#10156). The simple form, the `modal` and the `drawer`
variants do this, and so does the parent operation of a master-detail form,
whose header is a simple form. A master-detail child row already worked this
way (objectui#10108), and all of them use the same comparison. The `tabbed`,
`wizard` and `split` variants are not covered. That includes a master-detail
header laid out `tabbed`, and a simple form whose mobile `stepper` option shows it
one step at a time through the wizard. They still send every value the form
holds.

The comparison sends every field it cannot prove unchanged, because a field
wrongly judged unchanged would lose the user's edit while the server still
answers 200:

- `null` and `undefined` count as the same value. `''` is not a blank here, so
`null` and `''` are different.
- A number and a numeric string are different (`5` and `'5'`).
- A lookup id and the expanded lookup object are different.
- A `Date` and a date string are different. So are two date strings written in
different formats.
- Objects and arrays are equal only when they serialize identically.
Reordering their keys or elements makes them different.

A field the form changed by itself after the read is a change, so it is sent.
That covers a cascade clear or a value cleared when its field was hidden.

Some saves still send the full payload:

- **A save with nothing changed.** It sends every field, as it always has.
That keeps it a real request, with the same concurrency guard and a real
server record for `onSuccess`.
- **A form with no record read of its own.** This includes a create, a record
given as `initialData` or through inline `customFields`, and a save made
while a new record is still loading. Each of these sends every field.

After a successful save, the form treats the fields it just wrote as saved. A
form that stays open therefore compares its next save with the record as it
stands now, not as it was first read.

The concurrency guard is unchanged. The update still carries
`ifMatch` = the `updated_at` the form read, and a `409` still offers
**Keep editing** or **Overwrite**. **Overwrite** now resends only the changed
fields, so it no longer rewrites fields this user never touched.

⚠️ A host `submitHandler` gets the same payload in edit mode. Normally that is
the changed fields; after a save with nothing changed, it is the full payload.
A host that needs the whole record must read it itself.

## Integration with Data Sources

**The adapter is not a schema key.** A schema is a serialisable document; a live
Expand Down
36 changes: 31 additions & 5 deletions packages/plugin-form/src/DrawerForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,13 @@ import {
CONTAINER_GRID_COLS,
} from './autoLayout';
import { deriveFieldGroupSections, projectSectionDivider, resolveSectionCollapse } from './fieldGroups';
import { sanitizeFormData } from './sanitize';
import {
sanitizeFormData,
dirtyEditPayload,
snapshotLoadedRecord,
advanceLoadedRecord,
type LoadedRecordSnapshot,
} from './sanitize';
import { applyFieldPermissions, fieldWriteGate } from './fieldWriteGate';
import { seedCreateValues, omitServerResolvedDefaults } from './schemaDefaults';
import { resolveInitialRecord } from './initialRecord';
Expand Down Expand Up @@ -158,6 +164,10 @@ export interface DrawerFormSchema {
* 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`.
* In `edit` mode, for a record this form read itself, it hands over what it
* would have written: the fields that differ from that read, or the full
* sanitized payload when nothing changed (objectui#10156; the whole rule is
* on `ObjectFormSchema['submitHandler']`).
*
* `MasterDetailForm` supplies it to route the parent AND its child
* collections through one atomic `batchTransaction` (#2679 / ADR-0034
Expand Down Expand Up @@ -273,6 +283,12 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
// `initialData`/`initialValues` are objects callers commonly rebuild every
// render, and flashing the loading state for those would thrash.
const loadedRecordIdRef = useRef<string | number | undefined>(undefined);
// The record itself as read — the baseline an edit save diffs against, so
// only the fields that changed are written (objectui#10156). Kept apart from
// `formData`, which seeds the form and supplies the OCC token: advancing it
// after a save would reseed the one and move the other. Set by the `findOne`
// below and nowhere else, so a caller-supplied record is never a baseline.
const loadedRecordRef = useRef<LoadedRecordSnapshot | null>(null);

// Fetch initial data
useEffect(() => {
Expand All @@ -288,6 +304,8 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
let cancelled = false;
const fetchData = async () => {
if (schema.mode === 'create' || !schema.recordId) {
// Seeded from something other than a read: no baseline to diff against.
loadedRecordRef.current = null;
// Declared static defaults are this form's opening values (#4047) —
// see `schemaDefaults` for the create-only boundary and for why
// runtime defaults are left to the server.
Expand All @@ -297,6 +315,7 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
}

if (!dataSource) {
loadedRecordRef.current = null;
setFormData(resolveInitialRecord(schema));
setLoading(false);
return;
Expand All @@ -316,6 +335,7 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
const data = await dataSource.findOne(schema.objectName, schema.recordId);
if (cancelled) return;
loadedRecordIdRef.current = schema.recordId;
loadedRecordRef.current = snapshotLoadedRecord(schema, data);
setFormData(data || {});
} catch (err) {
if (cancelled) return;
Expand Down Expand Up @@ -471,11 +491,14 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
// 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.
// a real removal. An EDIT writes only the fields that differ from the
// record this form read (objectui#10156; `dirtyEditPayload` holds the
// rule, and sends whatever it cannot settle). 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;
: dirtyEditPayload(payload, loadedRecordRef.current, schema);

if (schema.submitHandler) {
// The host owns persistence (e.g. MasterDetailForm batching the parent
Expand All @@ -498,12 +521,15 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
dataSource,
objectName: schema.objectName,
recordId: schema.recordId,
payload,
payload: writePayload,
baseRecord: formData,
});
if (outcome.status === 'cancelled') return;
result = outcome.result;
}
// The write landed: a save from this still-open drawer diffs against the
// record as it now stands, not as first read.
loadedRecordRef.current = advanceLoadedRecord(loadedRecordRef.current, schema, writePayload);
if (schema.onSuccess) {
await schema.onSuccess(result);
}
Expand Down
37 changes: 31 additions & 6 deletions packages/plugin-form/src/ModalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,13 @@ import {
CONTAINER_GRID_COLS,
} from './autoLayout';
import { deriveFieldGroupSections, projectSectionDivider, resolveSectionCollapse } from './fieldGroups';
import { sanitizeFormData } from './sanitize';
import {
sanitizeFormData,
dirtyEditPayload,
snapshotLoadedRecord,
advanceLoadedRecord,
type LoadedRecordSnapshot,
} from './sanitize';
import { applyFieldPermissions, fieldWriteGate } from './fieldWriteGate';
import { seedCreateValues, omitServerResolvedDefaults } from './schemaDefaults';
import { resolveInitialRecord } from './initialRecord';
Expand Down Expand Up @@ -176,6 +182,10 @@ export interface ModalFormSchema {
* 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`.
* In `edit` mode, for a record this form read itself, it hands over what it
* would have written: the fields that differ from that read, or the full
* sanitized payload when nothing changed (objectui#10156; the whole rule is
* on `ObjectFormSchema['submitHandler']`).
*
* `MasterDetailForm` supplies it to route the parent AND its child
* collections through one atomic `batchTransaction` (#2679 / ADR-0034
Expand Down Expand Up @@ -349,6 +359,12 @@ export const ModalForm: React.FC<ModalFormProps> = ({
// `initialData`/`initialValues` are objects callers commonly rebuild every
// render, and flashing the loading state for those would thrash.
const loadedRecordIdRef = useRef<string | number | undefined>(undefined);
// The record itself as read — the baseline an edit save diffs against, so
// only the fields that changed are written (objectui#10156). Kept apart from
// `formData`, which seeds the form and supplies the OCC token: advancing it
// after a save would reseed the one and move the other. Set by the `findOne`
// below and nowhere else, so a caller-supplied record is never a baseline.
const loadedRecordRef = useRef<LoadedRecordSnapshot | null>(null);

// Fetch initial data
useEffect(() => {
Expand All @@ -364,6 +380,8 @@ export const ModalForm: React.FC<ModalFormProps> = ({
let cancelled = false;
const fetchData = async () => {
if (schema.mode === 'create' || !schema.recordId) {
// Seeded from something other than a read: no baseline to diff against.
loadedRecordRef.current = null;
// No persisted record to show, so the object's declared static
// `defaultValue`s are the form's opening values (#4047) — caller-
// supplied initial values still win. See `schemaDefaults` for why
Expand All @@ -375,6 +393,7 @@ export const ModalForm: React.FC<ModalFormProps> = ({
}

if (!dataSource) {
loadedRecordRef.current = null;
setFormData(resolveInitialRecord(schema));
setLoading(false);
return;
Expand All @@ -394,6 +413,7 @@ export const ModalForm: React.FC<ModalFormProps> = ({
const data = await dataSource.findOne(schema.objectName, schema.recordId);
if (cancelled) return;
loadedRecordIdRef.current = schema.recordId;
loadedRecordRef.current = snapshotLoadedRecord(schema, data);
setFormData(data || {});
} catch (err) {
if (cancelled) return;
Expand Down Expand Up @@ -501,12 +521,14 @@ export const ModalForm: React.FC<ModalFormProps> = ({
// 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.
// a real removal. An EDIT writes only the fields that differ from the
// record this form read (objectui#10156; `dirtyEditPayload` holds the
// rule, and sends whatever it cannot settle). 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;
: dirtyEditPayload(payload, loadedRecordRef.current, schema);

if (schema.submitHandler) {
// The host owns persistence (e.g. MasterDetailForm batching the parent
Expand All @@ -529,12 +551,15 @@ export const ModalForm: React.FC<ModalFormProps> = ({
dataSource,
objectName: schema.objectName,
recordId: schema.recordId,
payload,
payload: writePayload,
baseRecord: formData,
});
if (outcome.status === 'cancelled') return;
result = outcome.result;
}
// The write landed: a save from this still-open modal diffs against the
// record as it now stands, not as first read.
loadedRecordRef.current = advanceLoadedRecord(loadedRecordRef.current, schema, writePayload);
if (schema.onSuccess) {
await schema.onSuccess(result);
}
Expand Down
Loading
Loading