From 2705f6523f5932fbe4281d19d2572ad1237c201d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 07:28:38 +0000 Subject: [PATCH] fix(plugin-form): a required field with a runtime `defaultValue` is submittable on create (#4069) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@objectstack/spec` lets `defaultValue` be a runtime instruction rather than a value — the `DEFAULT_VALUE_TOKENS` family (`NOW()` / `current_user`) or a CEL Expression envelope — which `ObjectQL.applyFieldDefaults` resolves per insert for any field arriving absent or null. #4068 therefore leaves such fields empty in a create form: seeding the literal text `NOW()` and submitting it would suppress the very resolution the declaration asked for. Correct for an optional field; combined with `required: true` it deadlocked. The control opened empty, the client-side required rule refused the submit, and there was nothing sensible for the user to type. Measured on origin/main: `dataSource.create` was never called, on both the flat and sectioned paths. Per the maintainer's 2026-08-10 ruling on #4069 (option A), in CREATE mode a runtime `defaultValue` now suppresses the client-side required rule and the field is omitted from the payload — omitted, not sent empty, because a rendered control registers regardless of seeding and would otherwise carry `undefined` (a key a data source may still write) or `''` (neither absent nor null, so it stores a blank and defeats the declaration). Seeding and the required rule read ONE predicate, `isRuntimeDefault`, so a form can never seed a field it also refuses to submit. Edit mode, static literal defaults and typed values are unchanged, each pinned in both directions. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- ...eate-form-required-runtime-default-4069.md | 60 +++ packages/plugin-form/README.md | 33 ++ packages/plugin-form/src/DrawerForm.tsx | 25 +- packages/plugin-form/src/ModalForm.tsx | 25 +- packages/plugin-form/src/ObjectForm.tsx | 27 +- packages/plugin-form/src/SplitForm.tsx | 15 +- packages/plugin-form/src/TabbedForm.tsx | 15 +- packages/plugin-form/src/WizardForm.tsx | 17 +- .../plugin-form/src/createDefaults.test.tsx | 347 ++++++++++++++++++ packages/plugin-form/src/schemaDefaults.ts | 140 ++++++- packages/plugin-form/src/sectionFields.ts | 28 +- 11 files changed, 707 insertions(+), 25 deletions(-) create mode 100644 .changeset/create-form-required-runtime-default-4069.md diff --git a/.changeset/create-form-required-runtime-default-4069.md b/.changeset/create-form-required-runtime-default-4069.md new file mode 100644 index 0000000000..231303ad41 --- /dev/null +++ b/.changeset/create-form-required-runtime-default-4069.md @@ -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. diff --git a/packages/plugin-form/README.md b/packages/plugin-form/README.md index 81f0b86c16..48e6784322 100644 --- a/packages/plugin-form/README.md +++ b/packages/plugin-form/README.md @@ -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: diff --git a/packages/plugin-form/src/DrawerForm.tsx b/packages/plugin-form/src/DrawerForm.tsx index 4dfe68f137..74129b638d 100644 --- a/packages/plugin-form/src/DrawerForm.tsx +++ b/packages/plugin-form/src/DrawerForm.tsx @@ -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'; /** @@ -291,9 +296,12 @@ export const DrawerForm: React.FC = ({ 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) @@ -328,7 +336,10 @@ export const DrawerForm: React.FC = ({ 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, @@ -359,7 +370,13 @@ export const DrawerForm: React.FC = ({ 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. diff --git a/packages/plugin-form/src/ModalForm.tsx b/packages/plugin-form/src/ModalForm.tsx index 4284798958..60c655a48c 100644 --- a/packages/plugin-form/src/ModalForm.tsx +++ b/packages/plugin-form/src/ModalForm.tsx @@ -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'; @@ -367,9 +372,12 @@ export const ModalForm: React.FC = ({ 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) @@ -403,7 +411,10 @@ export const ModalForm: React.FC = ({ 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, @@ -452,7 +463,13 @@ export const ModalForm: React.FC = ({ 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. diff --git a/packages/plugin-form/src/ObjectForm.tsx b/packages/plugin-form/src/ObjectForm.tsx index 690d91ad60..1323b2ddbf 100644 --- a/packages/plugin-form/src/ObjectForm.tsx +++ b/packages/plugin-form/src/ObjectForm.tsx @@ -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 { @@ -570,7 +575,12 @@ const SimpleObjectForm: React.FC = ({ // 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, @@ -720,6 +730,14 @@ const SimpleObjectForm: React.FC = ({ // 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') { @@ -830,7 +848,10 @@ const SimpleObjectForm: React.FC = ({ // 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], diff --git a/packages/plugin-form/src/SplitForm.tsx b/packages/plugin-form/src/SplitForm.tsx index 2b5fe8c21d..b68bca4f70 100644 --- a/packages/plugin-form/src/SplitForm.tsx +++ b/packages/plugin-form/src/SplitForm.tsx @@ -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'; @@ -205,9 +205,12 @@ export const SplitForm: React.FC = ({ 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 @@ -222,7 +225,13 @@ export const SplitForm: React.FC = ({ 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. diff --git a/packages/plugin-form/src/TabbedForm.tsx b/packages/plugin-form/src/TabbedForm.tsx index 7225377d61..238b99b8a2 100644 --- a/packages/plugin-form/src/TabbedForm.tsx +++ b/packages/plugin-form/src/TabbedForm.tsx @@ -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'; @@ -280,9 +280,12 @@ export const TabbedForm: React.FC = ({ 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 @@ -298,7 +301,13 @@ export const TabbedForm: React.FC = ({ 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. diff --git a/packages/plugin-form/src/WizardForm.tsx b/packages/plugin-form/src/WizardForm.tsx index 58a223836a..6fc6ba5ee7 100644 --- a/packages/plugin-form/src/WizardForm.tsx +++ b/packages/plugin-form/src/WizardForm.tsx @@ -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'; @@ -319,9 +319,14 @@ export const WizardForm: React.FC = ({ 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 @@ -436,7 +441,13 @@ export const WizardForm: React.FC = ({ 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. diff --git a/packages/plugin-form/src/createDefaults.test.tsx b/packages/plugin-form/src/createDefaults.test.tsx index 7e047c19e4..81662db38f 100644 --- a/packages/plugin-form/src/createDefaults.test.tsx +++ b/packages/plugin-form/src/createDefaults.test.tsx @@ -41,7 +41,9 @@ import React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import { DEFAULT_VALUE_TOKENS } from '@objectstack/spec/data'; import { registerAllFields } from '@object-ui/fields'; +import { isRuntimeDefault, isRequiredInForm, isSeedableDefault } from './schemaDefaults'; import { ObjectForm } from './ObjectForm'; import { ModalForm } from './ModalForm'; import { DrawerForm } from './DrawerForm'; @@ -275,3 +277,348 @@ describe('ObjectForm — create-mode default seeding stays put (#4047)', () => { expect(triggerText('status')).not.toContain('Draft'); }); }); + +/** + * A REQUIRED field whose `defaultValue` is a RUNTIME instruction is + * submittable from a create form (#4069). + * + * The half above leaves such a field empty on purpose — the token is an + * instruction the server resolves per insert, and `applyFieldDefaults` only + * fills fields that arrive absent or null, so seeding the literal text `NOW()` + * would suppress the very resolution the declaration asked for. Correct for an + * optional field. Combined with `required: true` it deadlocked the form: + * + * ```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. Measured on `origin/main` before this change: + * `dataSource.create` was never called, on both the flat and the sectioned + * path. + * + * Ruled by the maintainer on 2026-08-10 (issue #4069, option A): in CREATE mode + * a runtime `defaultValue` SUPPRESSES the client-side required rule, and the + * field is omitted from the payload for the producer to resolve. Rejected in + * the same ruling: resolving these client-side (two implementations of one + * contract), serving resolved defaults from the server, and refusing the + * `required` + runtime-default combination at publish time — it is coherent + * authoring, storage-level required with a producer-guaranteed value. + * + * Four directions are pinned, because the suppression is wrong in three of them: + * + * 1. create + runtime default, left empty → submit SUCCEEDS and the field is + * ABSENT from the payload (present-but-empty would defeat the point: + * `applyFieldDefaults` skips a field that arrives with a value) + * 2. create + the user TYPES a value → that value is submitted. The + * suppression removes the "must not be empty" rule, not the field + * 3. create + a STATIC literal default → still enforced. That control was + * seeded (above), so clearing it removes a value that was really there + * 4. EDIT mode → unchanged in every case. The + * token was resolved at insert; blanking the column now is a real removal + * + * The runtime shapes are taken from `@objectstack/spec`'s own + * `DEFAULT_VALUE_TOKENS` rather than spelled out here, so a token added to the + * family tomorrow is pinned by this suite without an edit — and so the fixture + * cannot drift from the classifier the way a hand-copied list would. + */ + +/** A CEL Expression envelope — the non-token half of "the server resolves it". */ +const CEL_DEFAULT = { dialect: 'cel', source: 'today()' }; + +/** + * One required field per runtime-default SHAPE the spec defines. Every shape is + * rendered in the same form, so one submit covers the whole family. + */ +const RUNTIME_FIELDS: Array<{ name: string; what: string; defaultValue: unknown }> = [ + ...DEFAULT_VALUE_TOKENS.map((token, i) => ({ + name: `rt_token_${i}`, + what: `token ${String(token)}`, + defaultValue: token as unknown, + })), + { name: 'rt_cel', what: 'CEL Expression envelope', defaultValue: CEL_DEFAULT }, +]; + +/** + * `title` is required with NO default — the control the user genuinely must + * fill, and the contrast that proves the suppression is targeted rather than a + * blanket "create forms do not validate". + */ +const RUNTIME_OBJECT_SCHEMA = { + name: 'reminder', + fields: { + title: { type: 'text', label: 'Title', required: true }, + ...Object.fromEntries( + RUNTIME_FIELDS.map((f) => [ + f.name, + { type: 'text', label: f.what, required: true, defaultValue: f.defaultValue }, + ]), + ), + }, +}; + +const RUNTIME_SECTIONS = [ + { name: 'basics', label: 'Basics', fields: ['title', ...RUNTIME_FIELDS.map((f) => f.name)] }, +]; + +/** A required field carrying a STATIC literal default — the control seeding case. */ +const STATIC_OBJECT_SCHEMA = { + name: 'reminder', + fields: { + title: { type: 'text', label: 'Title', required: true, defaultValue: 'Untitled' }, + }, +}; +const STATIC_SECTIONS = [{ name: 'basics', label: 'Basics', fields: ['title'] }]; + +const fieldInput = (field: string) => + document.body.querySelector(`[data-field="${field}"] input`); + +/** Does this field show the required marker / announce `aria-required`? */ +const marksRequired = (field: string) => + document.body.querySelectorAll(`[data-field="${field}"] [data-required-marker]`).length > 0 || + fieldInput(field)?.getAttribute('aria-required') === 'true'; + +const awaitField = (field: string) => + waitFor(() => { + const el = fieldInput(field); + if (!el) throw new Error(`${field} input not rendered`); + return el; + }); + +describe.each(CONTAINERS)('%s — required + runtime `defaultValue` on create (#4069)', (_name, Container, formType) => { + const renderRuntimeCreate = (ds: any) => + render( + , + ); + + it('submits with the runtime-default fields left empty, and OMITS them from the payload', async () => { + const ds = makeDS(RUNTIME_OBJECT_SCHEMA); + renderRuntimeCreate(ds); + + const title = await awaitField('title'); + fireEvent.change(title, { target: { value: 'Ping me' } }); + submit(); + + // Before this change the submit was refused here and `create` was never + // called — with no value the user could supply to unblock it. + await waitFor(() => expect(ds.create).toHaveBeenCalled()); + const payload = ds.create.mock.calls[0][1]; + expect(payload).toMatchObject({ title: 'Ping me' }); + // ABSENT, not empty: `applyFieldDefaults` resolves the token only for a + // field that arrives absent or null, so an empty string in the payload + // would store "" and silently defeat the declaration. + for (const f of RUNTIME_FIELDS) expect(payload).not.toHaveProperty(f.name); + }); + + it('still enforces required on a field that declares NO default', async () => { + const ds = makeDS(RUNTIME_OBJECT_SCHEMA); + renderRuntimeCreate(ds); + + await awaitField('title'); // left empty on purpose + submit(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(ds.create).not.toHaveBeenCalled(); + expect(marksRequired('title')).toBe(true); + // The suppression is targeted: only the server-owned fields lose the rule. + for (const f of RUNTIME_FIELDS) expect(marksRequired(f.name)).toBe(false); + }); + + it('submits a value the user DOES type into a runtime-default field', async () => { + const ds = makeDS(RUNTIME_OBJECT_SCHEMA); + renderRuntimeCreate(ds); + + const title = await awaitField('title'); + fireEvent.change(title, { target: { value: 'Ping me' } }); + const typed = await awaitField(RUNTIME_FIELDS[0].name); + fireEvent.change(typed, { target: { value: 'typed by hand' } }); + submit(); + + await waitFor(() => expect(ds.create).toHaveBeenCalled()); + // What is suppressed is the "must not be empty" rule, never the field: a + // typed value outranks the declared default, exactly as it does today for + // a static one. + expect(ds.create.mock.calls[0][1]).toMatchObject({ + title: 'Ping me', + [RUNTIME_FIELDS[0].name]: 'typed by hand', + }); + }); + + it('does NOT suppress required for a STATIC literal default the user clears', async () => { + const ds = makeDS(STATIC_OBJECT_SCHEMA); + render( + , + ); + + // The control WAS seeded (#4047), so an empty one means the user emptied + // it — a removed value, not a value the producer will supply. + const title = await waitFor(() => { + const el = fieldInput('title'); + if (el?.value !== 'Untitled') throw new Error('static default not seeded yet'); + return el; + }); + expect(marksRequired('title')).toBe(true); + + fireEvent.change(title, { target: { value: '' } }); + submit(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(ds.create).not.toHaveBeenCalled(); + }); + + it('leaves EDIT mode alone — blanking a runtime-default required field is still refused', async () => { + const stored = { id: 'r1', title: 'Acme' }; + for (const f of RUNTIME_FIELDS) (stored as any)[f.name] = 'resolved at insert'; + const ds = makeDS(RUNTIME_OBJECT_SCHEMA, stored); + render( + , + ); + + const target = RUNTIME_FIELDS[0].name; + const el = await waitFor(() => { + const input = fieldInput(target); + if (input?.value !== 'resolved at insert') throw new Error('record not loaded yet'); + return input; + }); + // On a persisted row the token was resolved at insert; emptying the column + // now is a real removal, and the marker stays to say so. + expect(marksRequired(target)).toBe(true); + + fireEvent.change(el, { target: { value: '' } }); + submit(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(ds.update).not.toHaveBeenCalled(); + }); +}); + +describe('ObjectForm — required + runtime `defaultValue` on create (#4069)', () => { + // The flat container builds its fields on its own path (no sections), so the + // rule is pinned here too rather than assumed from the sectioned table. + it('submits with the runtime-default fields left empty, and OMITS them', async () => { + const ds = makeDS(RUNTIME_OBJECT_SCHEMA); + render( + , + ); + + const title = await awaitField('title'); + fireEvent.change(title, { target: { value: 'Ping me' } }); + submit(); + + await waitFor(() => expect(ds.create).toHaveBeenCalled()); + const payload = ds.create.mock.calls[0][1]; + expect(payload).toMatchObject({ title: 'Ping me' }); + for (const f of RUNTIME_FIELDS) expect(payload).not.toHaveProperty(f.name); + }); + + it('still refuses a required field that declares no default', async () => { + const ds = makeDS(RUNTIME_OBJECT_SCHEMA); + render( + , + ); + + await awaitField('title'); + submit(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(ds.create).not.toHaveBeenCalled(); + expect(marksRequired('title')).toBe(true); + }); + + it('leaves EDIT mode alone', async () => { + const stored: Record = { id: 'r1', title: 'Acme' }; + for (const f of RUNTIME_FIELDS) stored[f.name] = 'resolved at insert'; + const ds = makeDS(RUNTIME_OBJECT_SCHEMA, stored); + render( + , + ); + + const target = RUNTIME_FIELDS[0].name; + const el = await waitFor(() => { + const input = fieldInput(target); + if (input?.value !== 'resolved at insert') throw new Error('record not loaded yet'); + return input; + }); + expect(marksRequired(target)).toBe(true); + + fireEvent.change(el, { target: { value: '' } }); + submit(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(ds.update).not.toHaveBeenCalled(); + }); +}); + +describe('one classifier, two consumers (#4069)', () => { + // Seeding (#4047/#4068) and the create-mode required rule (#4069) are the + // same fact seen twice — a field whose value the producer supplies is + // neither seedable nor missing. They read ONE predicate; a second copy would + // be free to disagree about, say, a CEL envelope, and then a form would seed + // a field it also refuses to submit. + it.each([...DEFAULT_VALUE_TOKENS.map((t) => [String(t), t as unknown] as const), ['CEL envelope', CEL_DEFAULT as unknown] as const])( + '%s is a runtime default: not seedable, and not required on create', + (_what, value) => { + expect(isRuntimeDefault(value)).toBe(true); + expect(isSeedableDefault(value)).toBe(false); + expect(isRequiredInForm({ required: true, defaultValue: value }, true)).toBe(false); + // EDIT mode is untouched. + expect(isRequiredInForm({ required: true, defaultValue: value }, false)).toBe(true); + }, + ); + + it.each([['a static literal', 'draft'], ['a number', 0], ['a boolean', false], ['no default', undefined]])( + '%s does not suppress required in either mode', + (_what, value) => { + expect(isRuntimeDefault(value)).toBe(false); + expect(isRequiredInForm({ required: true, defaultValue: value }, true)).toBe(true); + expect(isRequiredInForm({ required: true, defaultValue: value }, false)).toBe(true); + }, + ); + + it('an optional field is never required, whatever it declares', () => { + expect(isRequiredInForm({ defaultValue: 'NOW()' }, true)).toBe(false); + expect(isRequiredInForm({ required: false, defaultValue: 'draft' }, false)).toBe(false); + expect(isRequiredInForm(undefined, true)).toBe(false); + }); +}); diff --git a/packages/plugin-form/src/schemaDefaults.ts b/packages/plugin-form/src/schemaDefaults.ts index 9c0a8da7cd..75d1f36211 100644 --- a/packages/plugin-form/src/schemaDefaults.ts +++ b/packages/plugin-form/src/schemaDefaults.ts @@ -7,7 +7,15 @@ */ /** - * Create-mode seeding of an object's declared field defaults (#4047). + * What an object's declared field `defaultValue`s mean to a CREATE form — + * which ones it seeds (#4047) and which ones excuse it from the client-side + * `required` rule (#4069). + * + * Both halves hang off ONE classifier, {@link isRuntimeDefault}: a default the + * server resolves per insert is neither seedable nor missing. Keeping the two + * consumers on one predicate is the point of this module — a second copy would + * be free to disagree about, say, a CEL envelope, and then a form would seed a + * field it also refuses to submit. * * ## What this is for * @@ -58,6 +66,7 @@ */ import { isRuntimeDefaultToken } from '@objectstack/spec/data'; +import { isMissingForRequired } from '@object-ui/core'; /** An object schema as the data source serves it (`{ fields: { [name]: def } }`). */ interface ObjectSchemaLike { @@ -80,6 +89,27 @@ function isExpressionEnvelope(v: unknown): boolean { ); } +/** + * Is this declared `defaultValue` a RUNTIME INSTRUCTION the server resolves per + * insert, rather than a literal value? + * + * True for the `DEFAULT_VALUE_TOKENS` family (`'NOW()'` / `'current_user'`) and + * for CEL/template Expression envelopes. This is THE classifier for "the + * producer owns this field's create value" — both consumers in this package + * read it, and neither may grow a second copy: + * + * 1. seeding (#4047 / #4068) — such a default is not seeded, because putting + * the literal text `NOW()` into a datetime input and submitting it + * suppresses the very resolution the declaration asked for; + * 2. the create-mode `required` rule (#4069) — see {@link isRequiredInForm}. + * + * The two are the same fact seen twice: a field whose value the server supplies + * is neither seedable nor missing. + */ +export function isRuntimeDefault(v: unknown): boolean { + return isRuntimeDefaultToken(v) || isExpressionEnvelope(v); +} + /** * Can this declared `defaultValue` be used as a form's initial value as-is? * @@ -88,8 +118,70 @@ function isExpressionEnvelope(v: unknown): boolean { */ export function isSeedableDefault(v: unknown): boolean { if (v === undefined || v === null) return false; - if (isRuntimeDefaultToken(v)) return false; - if (isExpressionEnvelope(v)) return false; + return !isRuntimeDefault(v); +} + +/** + * Does this form have no persisted record behind it — i.e. is it a CREATE form? + * + * The one "no persisted record" test, shared by everything that must agree + * about it: the containers' data-fetch branch, the default seeding (#4047) and + * the create-mode `required` suppression (#4069). Two spellings of this test + * WOULD drift — a form seeded as create but validated as edit is exactly the + * bug #4069 fixes, in mirror image. + */ +export function isCreateFormMode( + form: { mode?: string | null; recordId?: unknown } | null | undefined, +): boolean { + return form?.mode === 'create' || !form?.recordId; +} + +/** + * The `required` a form should ENFORCE on this field, given the mode (#4069). + * + * A field may declare `required: true` alongside a runtime `defaultValue`: + * + * ```ts + * remind_at: Field.datetime({ required: true, defaultValue: 'NOW()' }), + * ``` + * + * That is coherent authoring, not an error — storage-level required, with the + * value guaranteed by the producer (`ObjectQL.applyFieldDefaults` resolves the + * token for every field that arrives absent or null). But a CREATE form cannot + * seed it (see {@link isRuntimeDefault}), so the control opens empty; enforcing + * `required` there refuses the submit with *nothing sensible for the user to + * type* — the declaration already said what the value is, and omitting the + * field is precisely what makes the server supply it. The field is not + * "missing"; it is server-owned. + * + * So in CREATE mode a runtime default suppresses the rule. Three boundaries, + * each pinned in `createDefaults.test.tsx`: + * + * - **Create only.** An EDIT form shows a persisted row, where the token was + * already resolved at insert; blanking a required column there is a real + * removal of a value and stays refused. + * - **Runtime defaults only.** A STATIC literal default IS seeded into the + * control (#4068), so if the user clears it they have removed a value that + * was there — `required` still fires. + * - **The rule, not the field.** Suppression only removes the "must not be + * empty" check. A value the user DOES type is still submitted normally and + * wins over the declared default. + * + * Note this drops the required MARKER (and `aria-required`) too, since both are + * driven by this one boolean — which is the honest reading: in create mode the + * user really is not required to provide the value. Surfacing what the server + * WILL supply is issue #4069's option B, a separate follow-up card. + * + * Deliberately NOT extended to `requiredWhen` (the conditional-required CEL + * rule): that is resolved downstream in the form renderer against the live + * record, outside this package. + */ +export function isRequiredInForm( + field: { required?: unknown; defaultValue?: unknown } | null | undefined, + isCreateForm: boolean, +): boolean { + if (!field?.required) return false; + if (isCreateForm && isRuntimeDefault(field.defaultValue)) return false; return true; } @@ -125,3 +217,45 @@ export function seedCreateValues( ): Record { return { ...schemaDefaultValues(objectSchema), ...(initial ?? {}) }; } + +/** + * Drop the fields a CREATE payload must leave to the producer (#4069). + * + * The other half of {@link isRequiredInForm}: excusing a server-owned field + * from `required` is only half an answer if the form then submits the key + * anyway. A rendered control registers with the form whether or not anything + * seeded it, so an untouched runtime-default field reaches the payload as + * `undefined` — or as `''` once anything has focused it — and `undefined` is + * invisible to a `JSON.stringify` check while still being a KEY that a data + * source is free to translate into an explicit column write. + * + * `ObjectQL.applyFieldDefaults` resolves a declared default for a field that + * arrives absent or null. A blank string is neither, so submitting one stores + * `''` and silently defeats the declaration — the exact suppression #4068 + * avoided by not seeding the token in the first place. Omitting the key is what + * makes the server the single authority for the value. + * + * Only EMPTY values are dropped, and emptiness is `isMissingForRequired` — the + * very predicate the required rule uses, so "left empty" cannot come to mean + * two different things in the two halves of this fix. A value the user actually + * typed is submitted normally: the suppression is of the rule, not of the + * field. + * + * CREATE only. On an edit form the token was resolved at insert; a cleared + * column there is a deliberate removal, and dropping the key would silently + * discard the user's edit. + */ +export function omitServerResolvedDefaults( + data: Record, + objectSchema: ObjectSchemaLike | null | undefined, +): Record { + if (!data || typeof data !== 'object') return data; + const fields = objectSchema?.fields; + if (!fields || typeof fields !== 'object') return data; + const out: Record = {}; + for (const [key, value] of Object.entries(data)) { + if (isRuntimeDefault(fields[key]?.defaultValue) && isMissingForRequired(value)) continue; + out[key] = value; + } + return out; +} diff --git a/packages/plugin-form/src/sectionFields.ts b/packages/plugin-form/src/sectionFields.ts index 89d267b7ce..fb3595e163 100644 --- a/packages/plugin-form/src/sectionFields.ts +++ b/packages/plugin-form/src/sectionFields.ts @@ -27,6 +27,7 @@ import type { FormField } from '@object-ui/types'; import { mapFieldTypeToFormType, buildValidationRules } from '@object-ui/fields'; +import { isCreateFormMode, isRequiredInForm } from './schemaDefaults'; export interface SectionFieldsContext { /** Resolved object schema (`{ fields: { [name]: fieldDef } }`) or null. */ @@ -37,6 +38,15 @@ export interface SectionFieldsContext { readOnly?: boolean; /** Form mode — `view` forces every field disabled. */ mode?: 'create' | 'edit' | 'view'; + /** + * The record this form is editing, if any. Together with `mode` it answers + * "is there a persisted record behind this form", which decides whether a + * runtime `defaultValue` excuses the field from `required` (#4069) — see + * `isCreateFormMode`. Passed rather than derived from `mode` alone because + * the containers' own create branch is `mode === 'create' || !recordId`, and + * a form that omits `mode` entirely is still a create form to them. + */ + recordId?: unknown; /** * Translation-aware label resolver (from `useSafeFieldLabel`). * @@ -103,7 +113,11 @@ function fromObjectSchema(fieldName: string, ctx: SectionFieldsContext): FormFie // picker, and the label-association declaration is keyed on the widget that // actually renders (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 deliberately left empty for exactly that + // resolution, so refusing the submit would leave nothing to type. + required: isRequiredInForm(field, isCreateFormMode(ctx)), disabled: ctx.readOnly || ctx.mode === 'view' || field.readonly, placeholder: field.placeholder, description: field.help || field.description, @@ -153,7 +167,17 @@ export function normalizeSectionField( if (fd.label != null) base.label = fd.label; if (fd.placeholder != null) base.placeholder = fd.placeholder; if (fd.helpText != null) base.description = fd.helpText; - if (fd.required != null) base.required = fd.required; + // A view may restate `required` over the object field. Re-run the create-mode + // test on the EFFECTIVE value (#4069): what excuses the field is the runtime + // `defaultValue` on the object field, not which layer asserted `required` — + // a form view saying `required: true` over `defaultValue: 'NOW()'` makes the + // same claim the object schema does, and hits the same wall. + if (fd.required != null) { + base.required = isRequiredInForm( + { required: fd.required, defaultValue: ctx.objectSchema?.fields?.[fieldName]?.defaultValue }, + isCreateFormMode(ctx), + ); + } if (fd.readonly != null) base.disabled = fd.readonly || base.disabled; if (fd.immutable != null) base.immutable = fd.immutable; if (fd.hidden != null) base.hidden = fd.hidden;