diff --git a/.changeset/omega-nullable-nested-struct.md b/.changeset/omega-nullable-nested-struct.md new file mode 100644 index 000000000..6b97fadbb --- /dev/null +++ b/.changeset/omega-nullable-nested-struct.md @@ -0,0 +1,5 @@ +--- +"@effect-app/vue-components": patch +--- + +OmegaForm: deep-fill defaults for nullable nested structs. When a `S.NullOr(S.Struct(...))` field materialises because one child was filled, its untouched nullable siblings are now normalized to `null` (or their schema default) — in the live form state, and during validation and decoding — instead of being rejected as "field must not be empty". diff --git a/packages/vue-components/__tests__/OmegaForm/Defaults.values.test.ts b/packages/vue-components/__tests__/OmegaForm/Defaults.values.test.ts index 6fbaa8eb4..dec4f4981 100644 --- a/packages/vue-components/__tests__/OmegaForm/Defaults.values.test.ts +++ b/packages/vue-components/__tests__/OmegaForm/Defaults.values.test.ts @@ -3,6 +3,7 @@ import * as Effect from "effect-app/Effect" import * as S from "effect-app/Schema" import { describe, expect, it } from "vitest" import { defaultsValueFromSchema } from "../../src/components/OmegaForm" +import { fillNestedDefaults } from "../../src/components/OmegaForm/meta/defaults" describe("defaultsValueFromSchema", () => { it("extracts withConstructorDefault values", () => { @@ -56,3 +57,58 @@ describe("defaultsValueFromSchema", () => { expect(defaultsValueFromSchema(schema)).toEqual({ _tag: "A", v: "a-default", extra: "" }) }) }) + +describe("fillNestedDefaults", () => { + it("fills missing nullable children for a materialized nullable struct", () => { + const schema = S.Struct({ + override: S.NullOr(S.Struct({ + min: S.NullOr(S.NonNegativeNumber), + max: S.NullOr(S.NonNegativeNumber) + })) + }) + + expect(fillNestedDefaults(schema.ast, { override: { min: 100 } })).toEqual({ + override: { + min: 100, + max: null + } + }) + }) + + it("does not add fields from another tagged union branch", () => { + const schema = S.Union([ + S.TaggedStruct("A", { a: S.NullOr(S.String), common: S.String }), + S.TaggedStruct("B", { b: S.Number, nullableB: S.NullOr(S.Number) }) + ]) + + expect(fillNestedDefaults(schema.ast, { _tag: "B", b: 1 })).toEqual({ + _tag: "B", + b: 1 + }) + }) + + it("fills missing nullable children for materialized struct elements of an array", () => { + const schema = S.Struct({ + items: S.Array(S.NullOr(S.Struct({ + a: S.NullOr(S.String), + b: S.NullOr(S.String) + }))) + }) + + expect(fillNestedDefaults(schema.ast, { items: [{ a: "x" }, null] })).toEqual({ + items: [{ a: "x", b: null }, null] + }) + }) + + it("returns the same reference when there is nothing to fill", () => { + const schema = S.Struct({ + items: S.Array(S.NullOr(S.Struct({ + a: S.NullOr(S.String), + b: S.NullOr(S.String) + }))) + }) + + const value = { items: [{ a: "x", b: null }, null] } + expect(fillNestedDefaults(schema.ast, value)).toBe(value) + }) +}) diff --git a/packages/vue-components/__tests__/OmegaForm/NullableNestedStructValidation.test.ts b/packages/vue-components/__tests__/OmegaForm/NullableNestedStructValidation.test.ts new file mode 100644 index 000000000..7e8b487f3 --- /dev/null +++ b/packages/vue-components/__tests__/OmegaForm/NullableNestedStructValidation.test.ts @@ -0,0 +1,148 @@ +import { mount } from "@vue/test-utils" +import * as S from "effect-app/Schema" +import { describe, expect, it, vi } from "vitest" +import { useOmegaForm } from "../../src/components/OmegaForm" +import OmegaIntlProvider from "../OmegaIntlProvider.vue" + +/** + * Regression repro for the configurator `LinkedOption.override` schema. + * + * `override` is a nullable struct (`S.NullOr(S.Struct({...})).withConstructorDefault`) + * whose own children are themselves nullable (`S.NullOr(...)`). The form starts + * with `override === null`. As soon as the user fills ONE child (`override.min`), + * the struct materialises while its siblings (`override.max`, `override.readOnly`) + * are still `undefined`. + * + * `undefined` is not a valid member of `S.NullOr(...)`, so validation rejected the + * untouched siblings with "field must not be empty" and the form couldn't submit. + * The configurator worked around this by making every child `S.optional`; the + * correct behaviour is for OmegaForm to treat an untouched nullable child as + * `null` once its nullable parent struct materialises. + */ +describe("Nullable nested struct validation", () => { + const schema = S.Struct({ + optionId: S.NullOr(S.String), + override: S + .NullOr(S.Struct({ + min: S.NullOr(S.NonNegativeNumber), + max: S.NullOr(S.NonNegativeNumber), + readOnly: S.NullOr(S.Boolean).withConstructorDefault, + isInteger: S.optional(S.NullOr(S.Boolean)) + })) + .withConstructorDefault + }) + + it("submits when only one child of a nullable struct is filled", async () => { + let submittedValue: Record | null = null + + const wrapper = mount({ + components: { OmegaIntlProvider }, + template: ` + + + + + + `, + setup() { + const form = useOmegaForm(schema, { + onSubmit: async ({ value }) => { + submittedValue = value as Record + } + }) + return { form } + } + }) + + // The whole struct starts null. + await vi.waitFor(() => { + expect(wrapper.find("[data-testid=\"values\"]").text()).toContain("\"override\":null") + }) + + // User fills only `override.min`. + await wrapper.find("[data-testid=\"min-input\"]").setValue("100") + + // The struct materialised: its untouched nullable siblings are backfilled + // into the live form state, not left `undefined`. + await vi.waitFor(() => { + const valuesText = wrapper.find("[data-testid=\"values\"]").text() + expect(valuesText).toContain("\"min\":100") + expect(valuesText).toContain("\"max\":null") + expect(valuesText).toContain("\"readOnly\":null") + }) + + // Submit. + await wrapper.find("[data-testid=\"submit\"]").trigger("click") + + // The untouched nullable siblings must NOT block submission; they decode as null. + await vi.waitFor(() => { + expect(submittedValue).not.toBeNull() + }) + + expect(submittedValue).toMatchObject({ + override: { + min: 100, + max: null, + readOnly: null + } + }) + }) + + it("keeps an untouched nullable struct as null", async () => { + let submittedValue: Record | null = null + + const wrapper = mount({ + components: { OmegaIntlProvider }, + template: ` + + + + + + `, + setup() { + const form = useOmegaForm(schema, { + onSubmit: async ({ value }) => { + submittedValue = value as Record + } + }) + return { form } + } + }) + + await wrapper.find("[data-testid=\"submit\"]").trigger("click") + + await vi.waitFor(() => { + expect(submittedValue).not.toBeNull() + }) + + // Nothing was filled — `override` must stay null, not materialise. + expect(submittedValue).toMatchObject({ override: null }) + }) +}) diff --git a/packages/vue-components/src/components/OmegaForm/OmegaInternalInput.vue b/packages/vue-components/src/components/OmegaForm/OmegaInternalInput.vue index 9ef2e3a1e..8a37ea036 100644 --- a/packages/vue-components/src/components/OmegaForm/OmegaInternalInput.vue +++ b/packages/vue-components/src/components/OmegaForm/OmegaInternalInput.vue @@ -145,6 +145,12 @@ const handleChange: OmegaFieldInternalApi["handleChange"] = (value) } else { props.field.handleChange(value) } + + // A change here may have materialised a nullable struct (this field is one + // of its children); backfill the untouched siblings so the live `values` + // matches what validation and decoding see. + const form = props.field.form as { normalizeNullableStructs?: () => void } + form.normalizeNullableStructs?.() } // Note: Default value normalization (converting empty strings to null/undefined for nullable fields) diff --git a/packages/vue-components/src/components/OmegaForm/meta/defaults.ts b/packages/vue-components/src/components/OmegaForm/meta/defaults.ts index bd0a962bd..1855a68f3 100644 --- a/packages/vue-components/src/components/OmegaForm/meta/defaults.ts +++ b/packages/vue-components/src/components/OmegaForm/meta/defaults.ts @@ -33,10 +33,44 @@ type SchemaWithMembers = { members: readonly S.Schema[] } +type UnknownRecord = Record + function hasMembers(schema: any): schema is SchemaWithMembers { return schema && "members" in schema && Array.isArray(schema.members) } +const isRecord = (value: unknown): value is UnknownRecord => + value !== null && typeof value === "object" && !Array.isArray(value) + +const isNullishAst = (ast: S.AST.AST) => S.AST.isNull(ast) || S.AST.isUndefined(ast) + +const unionMembers = (ast: S.AST.AST): readonly S.AST.AST[] => { + const resolved = unwrapDeclaration(ast) + return S.AST.isUnion(resolved) + ? resolved.types.flatMap(unionMembers) + : [resolved] +} + +const literalValue = (ast: S.AST.AST): unknown => { + const resolved = unwrapDeclaration(ast) + if (S.AST.isLiteral(resolved)) return resolved.literal + if (S.AST.isUnion(resolved) && resolved.types.length === 1) { + return literalValue(resolved.types[0]) + } +} + +const findTaggedObjectMember = ( + members: readonly S.AST.Objects[], + value: unknown +): S.AST.Objects | undefined => { + if (!isRecord(value) || value._tag === undefined) return undefined + + return members.find((member) => { + const tagProp = member.propertySignatures.find((prop) => prop.name.toString() === "_tag") + return tagProp ? literalValue(tagProp.type) === value._tag : false + }) +} + // Internal implementation with WeakSet tracking export const defaultsValueFromSchema = ( schema: S.Schema, @@ -132,3 +166,96 @@ export const defaultsValueFromSchema = ( } } } + +/** + * Deep-fills a partial form value with schema defaults for any nullable + * struct that has **materialised**. + * + * A nullable struct (`S.NullOr(S.Struct(...))`) left `null` stays `null`, but + * once it materialises — e.g. the user filled a single child — its untouched + * children are filled with their schema default (or `null` when nullable). + * Without this, strict `S.NullOr(...)` children reject the leftover + * `undefined` with a spurious "field must not be empty" error. + * + * Only children of a struct reached *through a nullable union* are filled; + * the always-present root struct keeps whatever fields it already has, so the + * form's own default-value priority is left untouched. + * + * Reference-preserving: returns `value` unchanged (same reference) when there + * is nothing to fill, so callers can detect a no-op with `===` and the result + * is idempotent (`fill(fill(v)) === fill(v)`). + */ +export const fillNestedDefaults = ( + ast: S.AST.AST, + value: unknown, + fillMissing = false +): unknown => { + const resolved = unwrapDeclaration(ast) + + switch (resolved._tag) { + case "Union": { + if (value === null || value === undefined) return value + const members = unionMembers(resolved) + const objectMembers = members.filter(S.AST.isObjects) + if (objectMembers.length === 0) return value + + const hasNullishMember = members.some(isNullishAst) + const taggedMember = findTaggedObjectMember(objectMembers, value) + if (taggedMember) { + return fillNestedDefaults(taggedMember, value, fillMissing || hasNullishMember) + } + + if (hasNullishMember && objectMembers.length === 1) { + return fillNestedDefaults(objectMembers[0], value, true) + } + + return value + } + + case "Arrays": { + if (!Array.isArray(value)) return value + const element = resolved.rest[0] + if (!element) return value + let changed = false + const next = value.map((item) => { + const filled = fillNestedDefaults(element, item, fillMissing) + if (filled !== item) changed = true + return filled + }) + return changed ? next : value + } + + case "Objects": { + if (!isRecord(value)) return value + let result: UnknownRecord = value + let changed = false + const set = (key: string, next: unknown) => { + if (!changed) { + result = { ...value } + changed = true + } + result[key] = next + } + for (const prop of resolved.propertySignatures) { + const key = prop.name.toString() + const current = value[key] + if (current !== undefined) { + const filled = fillNestedDefaults(prop.type, current, fillMissing) + if (filled !== current) set(key, filled) + continue + } + if (!fillMissing || prop.type.context?.isOptional === true) continue + const propDefault = getDefaultFromAst(prop.type) + if (propDefault !== undefined) { + set(key, propDefault) + } else if (isNullableOrUndefined(prop.type) === "null") { + set(key, null) + } + } + return result + } + + default: + return value + } +} diff --git a/packages/vue-components/src/components/OmegaForm/types.ts b/packages/vue-components/src/components/OmegaForm/types.ts index 62f18a80d..12f0f320f 100644 --- a/packages/vue-components/src/components/OmegaForm/types.ts +++ b/packages/vue-components/src/components/OmegaForm/types.ts @@ -295,6 +295,12 @@ export interface OF extends OmegaFormApi { meta: MetaRecord unionMeta: Record> clear: () => void + /** + * Backfills the untouched children of any materialised nullable struct with + * their schema default (or `null`), so the live `values` matches what + * validation and decoding see. Called after a field change. + */ + normalizeNullableStructs: () => void i18nNamespace?: string ignorePreventCloseEvents?: boolean registerField: ( diff --git a/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts b/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts index 6e19834a2..0d7aae32a 100644 --- a/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts +++ b/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts @@ -1,14 +1,14 @@ /* eslint-disable @typescript-eslint/consistent-type-imports */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { type FormAsyncValidateOrFn, type FormValidateOrFn, revalidateLogic, type StandardSchemaV1, useForm } from "@tanstack/vue-form" +import { type DeepKeys, type DeepValue, type FormAsyncValidateOrFn, type FormValidateOrFn, revalidateLogic, type StandardSchemaV1, useForm } from "@tanstack/vue-form" import * as Context from "effect-app/Context" import * as S from "effect-app/Schema" import { type InjectionKey, watch } from "vue" import { eHoc, makeFieldMap } from "./errors" import { fHoc } from "./hocs" -import { generateMetaFromSchema } from "./meta/createMeta" -import { defaultsValueFromSchema } from "./meta/defaults" +import { generateMetaFromSchema, unwrapDeclaration } from "./meta/createMeta" +import { defaultsValueFromSchema, fillNestedDefaults } from "./meta/defaults" import { toFormSchema } from "./meta/redacted" import OmegaArray from "./OmegaArray.vue" import OmegaAutoGen from "./OmegaAutoGen.vue" @@ -51,11 +51,29 @@ export const useOmegaForm = < // (select) and literal-array (multiple) AST nodes with a localized // `message` so the formatter picks them up via `findMessage`. const localizedSchema = annotateLiteralUnionMessages(formCompatibleSchema, trans) - const standardSchema = toLocalizedStandardSchemaV1( + + // A nullable struct (`S.NullOr(S.Struct(...))`) has no slot in the form + // value until a child is filled. Once it materialises, its untouched + // children are still `undefined` — which strict `S.NullOr(...)` children + // reject. Deep-fill those children with their defaults before both + // validation and decoding so they validate as `null` (or their default). + const formAst = unwrapDeclaration(formCompatibleSchema.ast) + const normalizeFormValue = (value: unknown) => fillNestedDefaults(formAst, value) + + const baseStandardSchema = toLocalizedStandardSchemaV1( localizedSchema as any, trans ) - const decode = S.decodeUnknownEffectConcurrently(formCompatibleSchema) + const standardSchema: typeof baseStandardSchema = { + ...baseStandardSchema, + "~standard": { + ...baseStandardSchema["~standard"], + validate: (value) => baseStandardSchema["~standard"].validate(normalizeFormValue(value)) + } + } + + const baseDecode = S.decodeUnknownEffectConcurrently(formCompatibleSchema) + const decode = (value: From) => baseDecode(normalizeFormValue(value)) const { meta, unionMeta } = generateMetaFromSchema(formCompatibleSchema) @@ -100,6 +118,27 @@ export const useOmegaForm = < }) satisfies OmegaFormApi formHolder.form = form + // Keep the live form state consistent with the schema: when a nullable + // struct materialises (a child got a value), backfill its untouched + // children so `values` reflects what is validated and submitted. Called + // from the field change handler — the only point a struct can materialise. + const normalizeNullableStructs = () => { + const current = form.state.values + const filled = fillNestedDefaults(formAst, current) + if (filled === current || !filled || typeof filled !== "object") return + const currentRecord = current as Partial, unknown>> + const filledRecord = filled as Partial, unknown>> + for (const key of Object.keys(filledRecord) as Array>) { + if (filledRecord[key] !== currentRecord[key]) { + const field = key as DeepKeys + form.setFieldValue(field, filledRecord[key] as DeepValue, { + dontUpdateMeta: true, + dontValidate: true + }) + } + } + } + const clear = () => { Object.keys(meta).forEach((key: any) => { form.setFieldValue(key, undefined as any) @@ -134,6 +173,7 @@ export const useOmegaForm = < meta, unionMeta, clear, + normalizeNullableStructs, handleSubmit, // /** @experimental */ handleSubmitEffect, diff --git a/packages/vue-components/stories/OmegaForm.stories.ts b/packages/vue-components/stories/OmegaForm.stories.ts index c6fd4bb0f..c0e15ed21 100644 --- a/packages/vue-components/stories/OmegaForm.stories.ts +++ b/packages/vue-components/stories/OmegaForm.stories.ts @@ -23,6 +23,7 @@ import IntersectionExampleComponent from "./OmegaForm/IntersectionExample.vue" import MetaFormComponent from "./OmegaForm/Meta.vue" import NullComponent from "./OmegaForm/Null.vue" import NullableComponent from "./OmegaForm/Nullable.vue" +import NullableNestedStructComponent from "./OmegaForm/NullableNestedStruct.vue" import OptionalKeyComponent from "./OmegaForm/OptionalKey.vue" import PersistencyFormComponent from "./OmegaForm/PersistencyForm.vue" import ProgrammaticallyHandleSubmitCheckErrorsComponent from "./OmegaForm/ProgrammaticallyHandleSubmitCheckErrors.vue" @@ -138,6 +139,13 @@ export const Nullable: Story = { }) } +export const NullableNestedStruct: Story = { + render: () => ({ + components: { NullableNestedStructComponent }, + template: "" + }) +} + export const CreateUseFormWIthCustomInput: Story = { render: () => ({ components: { CreateUseFormWithCustomInputComponent }, diff --git a/packages/vue-components/stories/OmegaForm/NullableNestedStruct.vue b/packages/vue-components/stories/OmegaForm/NullableNestedStruct.vue new file mode 100644 index 000000000..b535ae9d1 --- /dev/null +++ b/packages/vue-components/stories/OmegaForm/NullableNestedStruct.vue @@ -0,0 +1,69 @@ + + + + +