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
60 changes: 60 additions & 0 deletions .changeset/create-form-required-runtime-default-4069.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
"@object-ui/plugin-form": patch
---

A required field whose `defaultValue` is a runtime token is submittable from a create form

`@objectstack/spec` lets a field's `defaultValue` be a runtime *instruction*
rather than a value — the `DEFAULT_VALUE_TOKENS` family (`'NOW()'`,
`'current_user'`) or a CEL Expression envelope. The server resolves those per
insert, in `ObjectQL.applyFieldDefaults`, for any field that arrives absent or
null, which is why a create form must leave them empty: seeding the literal text
`NOW()` into a datetime input and submitting it suppresses the very resolution
the declaration asked for.

Correct for an optional field. Combined with `required: true` it deadlocked:

```ts
remind_at: Field.datetime({ required: true, defaultValue: 'NOW()' }),
```

the control opened empty, the client-side required rule refused the submit, and
there was nothing sensible for the user to type — the declaration had already
said what the value is, and omitting the field is exactly what makes the server
supply it. Same shape as the `required` + static-default case, one layer down.

In **create** mode a runtime `defaultValue` now suppresses the client-side
`required` rule, and the field is omitted from the payload. The producer
guarantees the value at insert, so the field is not "missing" — it is
server-owned. `required: true` alongside a runtime default is coherent authoring
(storage-level required, producer-guaranteed), not an authoring error.

Both halves matter. Suppressing the rule alone would have been half an answer: a
rendered control registers with the form whether or not anything seeded it, so
an untouched runtime-default field still reached the payload as `undefined` — or
as `''` once anything focused it. `undefined` is invisible to a
`JSON.stringify` inspection while remaining a KEY a data source may translate
into an explicit column write, and `''` is neither absent nor null, so it stores
a blank and defeats the declaration outright.

Three boundaries came with it, each pinned in both directions:

- **Create only.** An edit form shows a persisted row, where the token was
resolved at insert; blanking a required column there is a real removal and is
still refused.
- **Runtime defaults only.** A static literal default *is* seeded into the
control, so if the user clears it they have removed a value that was really
there — `required` still fires.
- **The rule, not the field.** A value the user does type is submitted normally
and outranks the declared default. Only the "must not be empty" check is
suppressed.

Seeding and this rule read ONE predicate (`isRuntimeDefault`), so a form can
never seed a field it also refuses to submit. The suppression also drops the
required marker and `aria-required` for that field in create mode, since both
are driven by the same boolean — the honest reading, as the user really is not
required to provide the value. Surfacing what the server *will* supply, as a
non-authoritative preview, is a separate follow-up.

Not extended to `requiredWhen` (the conditional-required CEL rule), which is
resolved downstream in the form renderer against the live record.
33 changes: 33 additions & 0 deletions packages/plugin-form/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,39 @@ it; folding a default in over a column the record leaves unset would arm a
silent write of a value the user never chose, on the next save of any other
field.

#### `required` + a runtime default

A field may declare both, and it is coherent authoring — storage-level required,
with the value guaranteed by the producer:

```ts
remind_at: Field.datetime({ required: true, defaultValue: 'NOW()' }),
```

But the control opens empty (see the table), so enforcing `required` on it
refused the submit with nothing sensible for the user to type. In **create**
mode a runtime `defaultValue` therefore suppresses the client-side `required`
rule, and the field is **omitted from the payload** — omitted, not sent empty,
because `applyFieldDefaults` resolves the declaration only for a field that
arrives absent or null, and a blank string is neither.

| Mode | Declared | Left empty | Effect |
|---|---|---|---|
| create | `required` + a runtime default | yes | submits; the key is absent, and the server resolves it |
| create | `required` + a runtime default | no (user typed) | submits the typed value — it outranks the default |
| create | `required` + a static literal | user cleared the seeded control | refused: they removed a value that was really there |
| edit | `required`, anything | user blanked it | refused: the token was resolved at insert, so this is a real removal |

The required marker and `aria-required` go with the rule in the create case,
since one boolean drives all three — in that mode the user genuinely is not
required to provide the value. Showing what the server *will* supply, as a
non-authoritative preview, is a separate follow-up.

Both halves read one predicate (`isRuntimeDefault` in `schemaDefaults`), which
is what keeps a form from seeding a field it also refuses to submit. Not
extended to `requiredWhen`, the conditional-required CEL rule, which the form
renderer resolves against the live record.

### Column width of a sectioned form

A sectioned form renders as ONE grid, and two keys decide its shape:
Expand Down
25 changes: 21 additions & 4 deletions packages/plugin-form/src/DrawerForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@ import { mapFieldTypeToFormType, buildValidationRules } from '@object-ui/fields'
import { buildSectionFields as buildSectionFieldsShared } from './sectionFields';
import { applyAutoLayout } from './autoLayout';
import { sanitizeFormData } from './sanitize';
import { seedCreateValues } from './schemaDefaults';
import {
seedCreateValues,
isCreateFormMode,
isRequiredInForm,
omitServerResolvedDefaults,
} from './schemaDefaults';
import { useOccSave } from './occSave';

/**
Expand Down Expand Up @@ -291,9 +296,12 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
objectName: schema.objectName,
readOnly: schema.readOnly,
mode: schema.mode,
// Feeds the "no persisted record" test that decides whether a runtime
// `defaultValue` excuses a field from `required` (#4069).
recordId: schema.recordId,
fieldLabel,
}),
[objectSchema, schema.readOnly, schema.mode, schema.objectName, fieldLabel],
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, fieldLabel],
);

// Build fields from flat field list (when no sections provided)
Expand Down Expand Up @@ -328,7 +336,10 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
label: fieldLabel(schema.objectName, name, field.label || name),
// (type, multiple) decides the widget (objectui#3986) — see `sectionFields`.
type: mapFieldTypeToFormType(field.type, { multiple: field.multiple }),
required: field.required || false,
// Mode-aware, same rule as the sectioned path (#4069) — a runtime
// `defaultValue` is the server's to resolve, so a CREATE form does not
// refuse the submit over the empty control it deliberately left.
required: isRequiredInForm(field, isCreateFormMode(schema)),
disabled: schema.readOnly || schema.mode === 'view' || field.readonly,
placeholder: field.placeholder,
description: field.help || field.description,
Expand Down Expand Up @@ -359,7 +370,13 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
let result;
const payload = sanitizeFormData(data, objectSchema);
if (schema.mode === 'create') {
result = await dataSource.create(schema.objectName, payload);
// 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),
);
} 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
25 changes: 21 additions & 4 deletions packages/plugin-form/src/ModalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ import {
} from './autoLayout';
import { deriveFieldGroupSections } from './fieldGroups';
import { sanitizeFormData } from './sanitize';
import { seedCreateValues } from './schemaDefaults';
import {
seedCreateValues,
isCreateFormMode,
isRequiredInForm,
omitServerResolvedDefaults,
} from './schemaDefaults';
import { usePermissions } from '@object-ui/permissions';
import { useOccSave } from './occSave';

Expand Down Expand Up @@ -367,9 +372,12 @@ export const ModalForm: React.FC<ModalFormProps> = ({
objectName: schema.objectName,
readOnly: schema.readOnly,
mode: schema.mode,
// Feeds the "no persisted record" test that decides whether a runtime
// `defaultValue` excuses a field from `required` (#4069).
recordId: schema.recordId,
fieldLabel,
}),
[objectSchema, schema.readOnly, schema.mode, schema.objectName, fieldLabel],
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, fieldLabel],
);

// Build fields from flat field list (when no sections)
Expand Down Expand Up @@ -403,7 +411,10 @@ export const ModalForm: React.FC<ModalFormProps> = ({
label: fieldLabel(schema.objectName, name, field.label || name),
// (type, multiple) decides the widget (objectui#3986) — see `sectionFields`.
type: mapFieldTypeToFormType(field.type, { multiple: field.multiple }),
required: field.required || false,
// Mode-aware, same rule as the sectioned path (#4069) — a runtime
// `defaultValue` is the server's to resolve, so a CREATE form does not
// refuse the submit over the empty control it deliberately left.
required: isRequiredInForm(field, isCreateFormMode(schema)),
disabled: schema.readOnly || schema.mode === 'view' || field.readonly,
placeholder: field.placeholder,
description: field.help || field.description,
Expand Down Expand Up @@ -452,7 +463,13 @@ export const ModalForm: React.FC<ModalFormProps> = ({
payload = stripped;
}
if (schema.mode === 'create') {
result = await dataSource.create(schema.objectName, payload);
// 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),
);
} 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
27 changes: 24 additions & 3 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@ import {
} from './autoLayout';
import { deriveFieldGroupSections } from './fieldGroups';
import { sanitizeFormData } from './sanitize';
import { schemaDefaultValues } from './schemaDefaults';
import {
schemaDefaultValues,
isCreateFormMode,
isRequiredInForm,
omitServerResolvedDefaults,
} from './schemaDefaults';
import { useOccSave } from './occSave';

export interface ObjectFormProps {
Expand Down Expand Up @@ -570,7 +575,12 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
// label must be associated by IDREF — a fact declared per WIDGET, so
// the widget id has to carry the arity (objectui#3986).
type: mapFieldTypeToFormType(field.type, { multiple: field.multiple }),
required: field.required || false,
// Mode-aware: a CREATE form does not enforce `required` on a field
// whose `defaultValue` is a runtime instruction the server resolves
// at insert (#4069). The control is left empty on purpose so the
// server resolves it — refusing the submit would leave the user with
// nothing sensible to type.
required: isRequiredInForm(field, isCreateFormMode(schema)),
disabled: schema.readOnly || schema.mode === 'view' || field.readonly || managedBlanketLock,
placeholder: field.placeholder,
description: field.help || field.description,
Expand Down Expand Up @@ -720,6 +730,14 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
// forms `objectSchema` is a field-less stub, so pass null to strip only the
// server-managed keys rather than dropping every (schema-less) value.
let payload = sanitizeFormData(formData, hasInlineFields ? null : objectSchema);
// A CREATE payload omits the fields the producer owns (#4069): a rendered
// control registers even when nothing seeded it, so an untouched
// runtime-default field would ride along as `undefined`/`''` and defeat
// `applyFieldDefaults`, which only resolves a field that arrives absent or
// null. Create only — on an edit form a cleared column is a real removal.
if (isCreateFormMode(schema)) {
payload = omitServerResolvedDefaults(payload, hasInlineFields ? null : objectSchema);
}
// FLS defence-in-depth: never trust the client to include a field the user
// lacked edit access to — drop any that fail the write check.
if (perms?.isLoaded && payload && typeof payload === 'object') {
Expand Down Expand Up @@ -830,7 +848,10 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
// envelopes) — which put the literal text `NOW()` into a datetime input and
// then submitted it, suppressing the resolution the declaration asked for.
// `schemaDefaultValues` seeds static literals only; see that module.
const isCreateForm = !schema.recordId || schema.mode === 'create';
// Same shared "no persisted record" test the field builder above uses for
// the create-mode `required` suppression (#4069), so seeding and validation
// cannot disagree about which mode this form is in.
const isCreateForm = isCreateFormMode(schema);
const schemaDefaults = React.useMemo(
() => (isCreateForm ? schemaDefaultValues(objectSchema) : {}),
[objectSchema, isCreateForm],
Expand Down
15 changes: 12 additions & 3 deletions packages/plugin-form/src/SplitForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import type { FormField, DataSource } from '@object-ui/types';
import { cn } from '@object-ui/components';
import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react';
import { buildSectionFields as buildSectionFieldsShared } from './sectionFields';
import { seedCreateValues } from './schemaDefaults';
import { seedCreateValues, omitServerResolvedDefaults } from './schemaDefaults';
import { applyAutoColSpan, containerGridColsFor } from './autoLayout';
import { useOccSave } from './occSave';

Expand Down Expand Up @@ -205,9 +205,12 @@ export const SplitForm: React.FC<SplitFormProps> = ({
objectName: schema.objectName,
readOnly: schema.readOnly,
mode: schema.mode,
// Feeds the "no persisted record" test that decides whether a runtime
// `defaultValue` excuses a field from `required` (#4069).
recordId: schema.recordId,
fieldLabel,
}),
[objectSchema, schema.readOnly, schema.mode, schema.objectName, fieldLabel],
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, fieldLabel],
);

// Handle form submission
Expand All @@ -222,7 +225,13 @@ export const SplitForm: React.FC<SplitFormProps> = ({
try {
let result;
if (schema.mode === 'create') {
result = await dataSource.create(schema.objectName, data);
// 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),
);
} 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 Down
15 changes: 12 additions & 3 deletions packages/plugin-form/src/TabbedForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type { FormField, DataSource } from '@object-ui/types';
import { cn } from '@object-ui/components';
import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react';
import { buildSectionFields as buildSectionFieldsShared } from './sectionFields';
import { seedCreateValues } from './schemaDefaults';
import { seedCreateValues, omitServerResolvedDefaults } from './schemaDefaults';
import { applyAutoColSpan, containerGridColsFor } from './autoLayout';
import { useOccSave } from './occSave';

Expand Down Expand Up @@ -280,9 +280,12 @@ export const TabbedForm: React.FC<TabbedFormProps> = ({
objectName: schema.objectName,
readOnly: schema.readOnly,
mode: schema.mode,
// Feeds the "no persisted record" test that decides whether a runtime
// `defaultValue` excuses a field from `required` (#4069).
recordId: schema.recordId,
fieldLabel,
}),
[objectSchema, schema.readOnly, schema.mode, schema.objectName, fieldLabel],
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, fieldLabel],
);

// Handle form submission
Expand All @@ -298,7 +301,13 @@ export const TabbedForm: React.FC<TabbedFormProps> = ({
let result;

if (schema.mode === 'create') {
result = await dataSource.create(schema.objectName, data);
// 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),
);
} 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 Down
17 changes: 14 additions & 3 deletions packages/plugin-form/src/WizardForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { createSafeTranslation } from '@object-ui/i18n';
import { FormSectionContainer } from './FormSection';
import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react';
import { buildSectionFields as buildSectionFieldsShared } from './sectionFields';
import { seedCreateValues } from './schemaDefaults';
import { seedCreateValues, omitServerResolvedDefaults } from './schemaDefaults';
import { applyAutoColSpan, containerGridColsFor } from './autoLayout';
import { resolveSuccessNavigate, isSameOriginUrl, type SubmitBehavior } from './successBehavior';
import { useOccSave } from './occSave';
Expand Down Expand Up @@ -319,9 +319,14 @@ export const WizardForm: React.FC<WizardFormProps> = ({
objectName: schema.objectName,
readOnly: schema.readOnly,
mode: schema.mode,
// Feeds the "no persisted record" test that decides whether a runtime
// `defaultValue` excuses a field from `required` (#4069). The wizard's
// own final-submit gate (`missingRequiredByStep`) reads the `required`
// this produces, so it agrees with the renderer for free.
recordId: schema.recordId,
fieldLabel,
}),
[objectSchema, schema.readOnly, schema.mode, schema.objectName, fieldLabel],
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, fieldLabel],
);

// Current section fields
Expand Down Expand Up @@ -436,7 +441,13 @@ export const WizardForm: React.FC<WizardFormProps> = ({

let result;
if (schema.mode === 'create') {
result = await dataSource.create(schema.objectName, mergedData);
// 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),
);
} 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 Down
Loading
Loading