diff --git a/.changeset/giant-ears-chew.md b/.changeset/giant-ears-chew.md new file mode 100644 index 0000000000..95c64977f2 --- /dev/null +++ b/.changeset/giant-ears-chew.md @@ -0,0 +1,6 @@ +--- +"@effect-app/vue-components": minor +--- + +fix: form is set to dirty when null field included in form. +improve: String requirement handling diff --git a/packages/vue-components/__tests__/OmegaForm/IntersectionError.test.ts b/packages/vue-components/__tests__/OmegaForm/IntersectionError.test.ts index 9e1439097e..88998294b4 100644 --- a/packages/vue-components/__tests__/OmegaForm/IntersectionError.test.ts +++ b/packages/vue-components/__tests__/OmegaForm/IntersectionError.test.ts @@ -43,12 +43,12 @@ const mockComponents = { describe("OmegaForm Intersection/Union", () => { const AlphaSchema = S.Struct({ first: S.Literal("alpha"), - alpha: S.String + alpha: S.NonEmptyString }) const BetaSchema = S.Struct({ first: S.Literal("beta"), - beta: S.String + beta: S.NonEmptyString }) const MySchema = S.Struct({ diff --git a/packages/vue-components/src/components/OmegaForm/InputProps.ts b/packages/vue-components/src/components/OmegaForm/InputProps.ts index df259a7705..2933b60040 100644 --- a/packages/vue-components/src/components/OmegaForm/InputProps.ts +++ b/packages/vue-components/src/components/OmegaForm/InputProps.ts @@ -36,6 +36,7 @@ export type InputProps, TName extends Deep min?: number | false name: string modelValue: DeepValue + handleChange: (value: DeepValue) => void errorMessages: string[] error: boolean field: OmegaFieldInternalApi diff --git a/packages/vue-components/src/components/OmegaForm/OmegaFormStuff.ts b/packages/vue-components/src/components/OmegaForm/OmegaFormStuff.ts index ae3679ef1e..819482d310 100644 --- a/packages/vue-components/src/components/OmegaForm/OmegaFormStuff.ts +++ b/packages/vue-components/src/components/OmegaForm/OmegaFormStuff.ts @@ -1,5 +1,6 @@ import { type Effect, Option, pipe, type Record, S } from "effect-app" /* eslint-disable @typescript-eslint/no-explicit-any */ +import { getMetadataFromSchema } from "@effect-app/vue/form" import { type DeepKeys, type FieldAsyncValidateOrFn, type FieldValidateOrFn, type FormApi, type FormAsyncValidateOrFn, type FormOptions, type FormState, type FormValidateOrFn, type StandardSchemaV1, type VueFormApi } from "@tanstack/vue-form" import { type RuntimeFiber } from "effect/Fiber" import { getTransformationFrom, useIntl } from "../../utils" @@ -492,7 +493,12 @@ export const createMeta = ( const newMeta = createMeta({ parent: key, property: p.type, - meta: { required: isRequired, nullableOrUndefined } + meta: { + // an empty string is valid for a S.String field, so we should not mark it as required + // TODO: handle this better via the createMeta minLength parsing + required: isRequired && (p.type._tag !== "StringKeyword" || getMetadataFromSchema(p.type).minLength), + nullableOrUndefined + } }) acc[key as NestedKeyOf] = newMeta as FieldMeta diff --git a/packages/vue-components/src/components/OmegaForm/OmegaInputVuetify.vue b/packages/vue-components/src/components/OmegaForm/OmegaInputVuetify.vue index 7f20ee576f..fcb67aee3c 100644 --- a/packages/vue-components/src/components/OmegaForm/OmegaInputVuetify.vue +++ b/packages/vue-components/src/components/OmegaForm/OmegaInputVuetify.vue @@ -15,7 +15,7 @@ ripple v-bind="$attrs" :model-value="vuetifyValue" - @change="(e: any) => inputProps.field.handleChange(e.target.checked)" + @change="(e: any) => inputProps.handleChange(e.target.checked)" /> @@ -77,7 +77,7 @@ :error="inputProps.error" v-bind="$attrs" :model-value="vuetifyValue" - @update:model-value="inputProps.field.handleChange" + @update:model-value="inputProps.handleChange" > diff --git a/packages/vue-components/src/components/OmegaForm/OmegaInternalInput.vue b/packages/vue-components/src/components/OmegaForm/OmegaInternalInput.vue index 3381d14db4..6547fde21b 100644 --- a/packages/vue-components/src/components/OmegaForm/OmegaInternalInput.vue +++ b/packages/vue-components/src/components/OmegaForm/OmegaInternalInput.vue @@ -20,7 +20,7 @@ generic="From extends Record, Name extends DeepKeys" > import { type DeepKeys, useStore } from "@tanstack/vue-form" -import { computed, type ComputedRef, getCurrentInstance, nextTick, onMounted, onUnmounted, ref, useId, watch, watchEffect } from "vue" +import { computed, type ComputedRef, getCurrentInstance, onMounted, onUnmounted, ref, useId, watch, watchEffect } from "vue" import type { InputProps, OmegaFieldInternalApi } from "./InputProps" import type { FieldValidators, MetaRecord, NestedKeyOf, TypeOverride } from "./OmegaFormStuff" import OmegaInputVuetify from "./OmegaInputVuetify.vue" @@ -67,31 +67,35 @@ const isFalsyButNotZero = (value: unknown): boolean => { } // we remove value and errors when the field is empty and not required -// watchEffect will trigger infinite times with both free fieldValue and errors, so bet to watch a stupid boolean -watch( - () => !!fieldValue.value, - () => { - if (isFalsyButNotZero(fieldValue.value) && props.meta?.type !== "boolean") { - nextTick(() => { - fieldApi.setValue( - props.meta?.nullableOrUndefined === "undefined" - ? undefined - : null as any - ) - }) - } + +// convert nullish value to null or undefined based on schema +const handleChange: OmegaFieldInternalApi["handleChange"] = (value) => { + if (isFalsyButNotZero(value) && props.meta?.type !== "boolean") { + props.field.handleChange( + props.meta?.nullableOrUndefined === "undefined" + ? undefined + // eslint-disable-next-line @typescript-eslint/no-explicit-any + : null as any + ) + } else { + props.field.handleChange(value) } -) +} +// 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(() => { if ( !fieldValue.value && !props.meta?.required && props.meta?.nullableOrUndefined === "null" ) { + const isDirty = fieldState.value.meta.isDirty fieldApi.setValue(null as any) + // make sure we restore the previous dirty state.. + fieldApi.setMeta((_) => ({ ..._, isDirty })) } }) + const { mapError, removeError, showErrors, showErrorsOn } = (props.field.form as any).errorContext // todo; update types to include extended Omega Form props const realDirty = ref(false) @@ -153,6 +157,7 @@ const inputProps: ComputedRef> = computed(() => ({ min: props.meta?.type === "number" && props.meta?.minimum, name: props.field.name, modelValue: props.field.state.value, + handleChange, errorMessages: showedErrors.value, error: !!showedErrors.value.length, field: props.field, diff --git a/packages/vue-components/stories/OmegaForm.stories.ts b/packages/vue-components/stories/OmegaForm.stories.ts index 0b0f0dd0de..0793977789 100644 --- a/packages/vue-components/stories/OmegaForm.stories.ts +++ b/packages/vue-components/stories/OmegaForm.stories.ts @@ -14,6 +14,7 @@ import EmailFormComponent from "./OmegaForm/EmailForm.vue" import FormInputComponent from "./OmegaForm/form.Input.vue" import IntersectionExampleComponent from "./OmegaForm/IntersectionExample.vue" import MetaFormComponent from "./OmegaForm/Meta.vue" +import NullComponent from "./OmegaForm/Null.vue" import OneHundredWaysToWriteAFormComponent from "./OmegaForm/OneHundredWaysToWriteAForm.vue" import PersistencyFormComponent from "./OmegaForm/PersistencyForm.vue" import ProgrammaticallyHandleSubmitCheckErrorsComponent from "./OmegaForm/ProgrammaticallyHandleSubmitCheckErrors.vue" @@ -183,3 +184,10 @@ export const ProgrammaticallyHandleSubmitCheckErrors: Story = { template: "" }) } + +export const Null: Story = { + render: () => ({ + components: { NullComponent }, + template: "" + }) +} diff --git a/packages/vue-components/stories/OmegaForm/ComplexForm.vue b/packages/vue-components/stories/OmegaForm/ComplexForm.vue index 6a8df5356d..a45d3673f9 100644 --- a/packages/vue-components/stories/OmegaForm/ComplexForm.vue +++ b/packages/vue-components/stories/OmegaForm/ComplexForm.vue @@ -1,5 +1,6 @@