diff --git a/.changeset/cool-parrots-kick.md b/.changeset/cool-parrots-kick.md new file mode 100644 index 0000000000..6c66a26044 --- /dev/null +++ b/.changeset/cool-parrots-kick.md @@ -0,0 +1,5 @@ +--- +"@effect-app/vue-components": patch +--- + +Revert default Values strategy and moves everything to hook instead then on Vue OnMounted diff --git a/packages/vue-components/__tests__/OmegaForm/NullableFieldInitialization.test.ts b/packages/vue-components/__tests__/OmegaForm/NullableFieldInitialization.test.ts new file mode 100644 index 0000000000..e5ed23399e --- /dev/null +++ b/packages/vue-components/__tests__/OmegaForm/NullableFieldInitialization.test.ts @@ -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 | null = null + let submitError = null + + const wrapper = mount({ + components: { + OmegaIntlProvider + }, + template: ` + + + + + + `, + 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: ` + + + + + + `, + 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") + } + }) +}) diff --git a/packages/vue-components/src/components/OmegaForm/OmegaInternalInput.vue b/packages/vue-components/src/components/OmegaForm/OmegaInternalInput.vue index b0140ee97d..82ab17a9e8 100644 --- a/packages/vue-components/src/components/OmegaForm/OmegaInternalInput.vue +++ b/packages/vue-components/src/components/OmegaForm/OmegaInternalInput.vue @@ -14,8 +14,8 @@ lang="ts" generic="From extends Record, Name extends DeepKeys" > -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" @@ -112,35 +112,8 @@ const handleChange: OmegaFieldInternalApi["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) - } else if (props.meta?.nullableOrUndefined === "undefined") { - fieldApi.setValue(undefined as DeepValue) - } else { - // For required fields, initialize with appropriate empty value - if (props.meta?.type === "string") { - fieldApi.setValue("" as DeepValue) - } 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) - } - // 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) => { const handler3 = { diff --git a/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts b/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts index ef870ec81e..7e7229119f 100644 --- a/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts +++ b/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts @@ -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): Partial => { + 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 @@ -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) } }) diff --git a/packages/vue-components/stories/OmegaForm/SimpleFormVuetifyDefault.vue b/packages/vue-components/stories/OmegaForm/SimpleFormVuetifyDefault.vue index d2232ee987..9615136f39 100644 --- a/packages/vue-components/stories/OmegaForm/SimpleFormVuetifyDefault.vue +++ b/packages/vue-components/stories/OmegaForm/SimpleFormVuetifyDefault.vue @@ -5,7 +5,14 @@ label="aString" name="aString" /> +
{{ values }}
+ + + @@ -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 +})