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/cool-parrots-kick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect-app/vue-components": patch
---

Revert default Values strategy and moves everything to hook instead then on Vue OnMounted
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { mount } from "@vue/test-utils"
import { S } from "effect-app"
import { describe, expect, it, vi } from "vitest"
import { useOmegaForm } from "../../src/components/OmegaForm"
import OmegaIntlProvider from "../OmegaIntlProvider.vue"

describe("Nullable field initialization", () => {
it("should initialize nullable fields with null when missing from defaultValues", async () => {
const schema = S.Struct({
aString: S.NullOr(S.NonEmptyString255).withDefault,
bString: S.NullOr(S.NonEmptyString255).withDefault
})

let submittedValue: Record<string, unknown> | null = null
let submitError = 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="aString"
name="aString"
>
<template #default="{ field, state }">
<input
:id="field.name"
:value="state.value ?? ''"
data-testid="aString-input"
@input="field.handleChange($event.target.value)"
/>
</template>
</component>
<component :is="form.Input"
label="bString"
name="bString"
>
<template #default="{ field, state }">
<input
:id="field.name"
:value="state.value ?? ''"
data-testid="bString-input"
@input="field.handleChange($event.target.value)"
/>
</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, {
defaultValues: {
aString: ""
},
onSubmit: async ({ value }) => {
try {
submittedValue = value
} catch (error) {
submitError = error
}
}
})
return { form }
}
})

await wrapper.vm.$nextTick()

// Check that both fields are initialized with null (not empty string)
const valuesText = wrapper.find("[data-testid=\"values\"]").text()
expect(valuesText).toContain("\"aString\":null")
expect(valuesText).toContain("\"bString\":null")

// Submit the form
await wrapper.find("[data-testid=\"submit\"]").trigger("click")

// Wait for the submission to complete
await vi.waitFor(() => {
expect(submittedValue).not.toBeNull()
})

// Check that form submitted successfully without errors
expect(submitError).toBeNull()
expect(submittedValue).toEqual({
aString: null,
bString: null
})
})

it("should convert empty string to null for nullable fields", async () => {
const schema = S.Struct({
aString: S.NullOr(S.NonEmptyString255).withDefault,
bString: S.NonEmptyString255
})

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="aString"
name="aString"
>
<template #default="{ field, state }">
<input
:id="field.name"
:value="state.value ?? ''"
data-testid="aString-input"
@input="field.handleChange($event.target.value)"
/>
</template>
</component>
<component :is="form.Input"
label="bString"
name="bString"
>
<template #default="{ field, state }">
<input
:id="field.name"
:value="state.value ?? ''"
data-testid="bString-input"
@input="field.handleChange($event.target.value)"
/>
</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, {
defaultValues: {
aString: ""
},
onSubmit: async () => {
// Should not be called since bString is required and missing
}
})
return { form }
}
})

await wrapper.vm.$nextTick()

// aString should be null (nullable field with empty string)
const valuesText = wrapper.find("[data-testid=\"values\"]").text()
expect(valuesText).toContain("\"aString\":null")

// Submit the form - should fail because bString is required
await wrapper.find("[data-testid=\"submit\"]").trigger("click")
await wrapper.vm.$nextTick()

// Check that there's an error for bString
const errors = wrapper.find("[data-testid=\"omega-errors\"]")
if (errors.exists()) {
expect(errors.text()).toContain("bString")
}
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
lang="ts"
generic="From extends Record<PropertyKey, any>, Name extends DeepKeys<From>"
>
import { type DeepKeys, type DeepValue, useStore } from "@tanstack/vue-form"
import { computed, type ComputedRef, getCurrentInstance, onMounted, useId } from "vue"
import { type DeepKeys, useStore } from "@tanstack/vue-form"
import { computed, type ComputedRef, getCurrentInstance, useId } from "vue"
import type { InputProps, OmegaFieldInternalApi } from "./InputProps"
import type { FieldValidators, MetaRecord, NestedKeyOf, TypeOverride } from "./OmegaFormStuff"
import OmegaInputVuetify from "./OmegaInputVuetify.vue"
Expand Down Expand Up @@ -112,35 +112,8 @@ const handleChange: OmegaFieldInternalApi<From, Name>["handleChange"] = (value)
props.field.setMeta((m) => ({ ...m, errorMap: { ...m.errorMap, onSubmit: undefined } }))
}

// TODO: it would be cleaner when default values are handled in the form initialization via Schema or by the one using the form component..
onMounted(() => {
// Initialize field value on mount if it doesn't exist
if (fieldValue.value === undefined) {
const isDirty = fieldState.value.meta.isDirty
// make sure we restore the previous dirty state..
fieldApi.setMeta((_) => ({ ..._, isDirty }))

if (isRequired.value) return

// Set appropriate default value based on field type and nullability
if (props.meta?.nullableOrUndefined === "null") {
fieldApi.setValue(null as DeepValue<From, Name>)
} else if (props.meta?.nullableOrUndefined === "undefined") {
fieldApi.setValue(undefined as DeepValue<From, Name>)
} else {
// For required fields, initialize with appropriate empty value
if (props.meta?.type === "string") {
fieldApi.setValue("" as DeepValue<From, Name>)
} else if (props.meta?.type === "number") {
// Don't initialize number fields to avoid setting them to 0
// Leave as undefined so validation will catch it
} else if (props.meta?.type === "boolean") {
fieldApi.setValue(false as DeepValue<From, Name>)
}
// For other types, leave undefined so validation will catch missing required fields
}
}
})
// Note: Default value normalization (converting empty strings to null/undefined for nullable fields)
// is now handled at the form level in useOmegaForm, not here in the component

const wrapField = (field: OmegaFieldInternalApi<From, Name>) => {
const handler3 = {
Expand Down
47 changes: 41 additions & 6 deletions packages/vue-components/src/components/OmegaForm/useOmegaForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -783,14 +783,50 @@ export const useOmegaForm = <
return target
}

// Normalize default values based on schema metadata
// Convert empty strings to null/undefined for nullable fields
// Also initialize missing nullable fields with null/undefined
const normalizeDefaultValues = (values: Partial<From>): Partial<From> => {
const normalized: any = { ...values }

// Process all fields in the schema metadata
for (const key in meta) {
const fieldMeta = meta[key as keyof typeof meta]
const value = normalized[key]

// Check if the value is falsy (but not boolean false or zero)
const isFalsyButNotZero = value == null || value === false || value === "" || Number.isNaN(value)
const isFalsy = isFalsyButNotZero && value !== false && value !== 0

if (
fieldMeta
&& !fieldMeta.required
&& fieldMeta.nullableOrUndefined
&& fieldMeta.type !== "boolean"
) {
// If value is missing or falsy, set to null or undefined based on schema
if (value === undefined || isFalsy) {
normalized[key] = fieldMeta.nullableOrUndefined === "undefined" ? undefined : null
}
}
}

return normalized
}

const defaultValues = computed(() => {
// Normalize tanstack default values at the beginning
const normalizedTanstackDefaults = tanstackFormOptions?.defaultValues
? normalizeDefaultValues(tanstackFormOptions.defaultValues)
: undefined

if (
tanstackFormOptions?.defaultValues
normalizedTanstackDefaults
&& !omegaConfig?.persistency?.overrideDefaultValues
) {
// defaultValues from tanstack are not partial,
// so if ovverrideDefaultValues is false we simply return them
return tanstackFormOptions?.defaultValues
// so if ovverrideDefaultValues is false we return the normalized values
return normalizedTanstackDefaults
}

// we are here because there are no default values from tankstack
Expand Down Expand Up @@ -839,12 +875,11 @@ export const useOmegaForm = <
// to be sure we have a valid object at the end of the gathering process
defValuesPatch ??= {}

if (tanstackFormOptions?.defaultValues == undefined) {
if (!normalizedTanstackDefaults) {
// we just return what we gathered from the query/storage
return defValuesPatch
} else {
const startingDefValues = tanstackFormOptions?.defaultValues
return deepMerge(startingDefValues, defValuesPatch)
return deepMerge(normalizedTanstackDefaults, defValuesPatch)
}
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@
label="aString"
name="aString"
/>
<form.Input
label="bString"
name="bString"
/>
<pre>{{ values }}</pre>
<button>submit</button>

<form.Errors />
</template>
</form.Form>
</template>
Expand All @@ -14,5 +21,17 @@
import { S } from "effect-app"
import { useOmegaForm } from "../../src"

const form = useOmegaForm(S.Struct({ aString: S.UndefinedOr(S.String) }))
const schema = S.Struct({
aString: S.NullOr(S.NonEmptyString255).withDefault,
bString: S.NullOr(S.NonEmptyString255).withDefault
})
const defaultValues = {
aString: ""
}
const form = useOmegaForm(schema, {
onSubmit: async (values) => {
console.log(values)
},
defaultValues
})
</script>