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
5 changes: 5 additions & 0 deletions .changeset/omega-nullable-nested-struct.md
Original file line number Diff line number Diff line change
@@ -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".
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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)
})
})
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null = null

const wrapper = mount({
components: { OmegaIntlProvider },
template: `
<OmegaIntlProvider>
<component :is="form.Form" :subscribe="['values']">
<template #default="{ subscribedValues: { values } }">
<div data-testid="values">{{ JSON.stringify(values) }}</div>
<component :is="form.Input" label="min" name="override.min">
<template #default="{ field, state }">
<input
:id="field.name"
:value="state.value ?? ''"
data-testid="min-input"
@input="field.handleChange(Number($event.target.value))"
/>
</template>
</component>
<component :is="form.Input" label="max" name="override.max">
<template #default="{ field, state }">
<input :id="field.name" :value="state.value ?? ''" data-testid="max-input" />
</template>
</component>
<component :is="form.Input" label="readOnly" name="override.readOnly">
<template #default="{ field, state }">
<input type="checkbox" :id="field.name" :checked="state.value ?? false" data-testid="readOnly-input" />
</template>
</component>
<component :is="form.Errors" />
<button type="submit" data-testid="submit" @click.prevent="form.handleSubmit()">
submit
</button>
</template>
</component>
</OmegaIntlProvider>
`,
setup() {
const form = useOmegaForm(schema, {
onSubmit: async ({ value }) => {
submittedValue = value as Record<string, unknown>
}
})
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<string, unknown> | null = null

const wrapper = mount({
components: { OmegaIntlProvider },
template: `
<OmegaIntlProvider>
<component :is="form.Form">
<button type="submit" data-testid="submit" @click.prevent="form.handleSubmit()">submit</button>
</component>
</OmegaIntlProvider>
`,
setup() {
const form = useOmegaForm(schema, {
onSubmit: async ({ value }) => {
submittedValue = value as Record<string, unknown>
}
})
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 })
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@ const handleChange: OmegaFieldInternalApi<From, Name>["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)
Expand Down
127 changes: 127 additions & 0 deletions packages/vue-components/src/components/OmegaForm/meta/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,44 @@ type SchemaWithMembers = {
members: readonly S.Schema<any>[]
}

type UnknownRecord = Record<string, unknown>

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<any>,
Expand Down Expand Up @@ -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
}
}
6 changes: 6 additions & 0 deletions packages/vue-components/src/components/OmegaForm/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,12 @@ export interface OF<From, To> extends OmegaFormApi<From, To> {
meta: MetaRecord<From>
unionMeta: Record<string, MetaRecord<From>>
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: (
Expand Down
Loading
Loading