Skip to content
Merged
6 changes: 6 additions & 0 deletions .changeset/giant-ears-chew.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export type InputProps<From extends Record<PropertyKey, any>, TName extends Deep
min?: number | false
name: string
modelValue: DeepValue<From, TName>
handleChange: (value: DeepValue<From, TName>) => void
errorMessages: string[]
error: boolean
field: OmegaFieldInternalApi<From, TName>
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -492,7 +493,12 @@ export const createMeta = <T = any>(
const newMeta = createMeta<T>({
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<T>] = newMeta as FieldMeta
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
/>
<v-text-field
v-if="inputProps.type === 'email' || inputProps.type === 'string' || inputProps.type === 'password'"
Expand All @@ -30,7 +30,7 @@
:error="inputProps.error"
v-bind="$attrs"
:model-value="vuetifyValue"
@update:model-value="inputProps.field.handleChange"
@update:model-value="inputProps.handleChange"
/>
<v-textarea
v-if="inputProps.type === 'text'"
Expand All @@ -44,7 +44,7 @@
:error="inputProps.error"
v-bind="$attrs"
:model-value="vuetifyValue"
@update:model-value="inputProps.field.handleChange"
@update:model-value="inputProps.handleChange"
/>
<component
:is="inputProps.type === 'range' ? 'v-slider' : 'v-text-field'"
Expand All @@ -62,9 +62,9 @@
:model-value="vuetifyValue"
@update:model-value="(e: any) => {
if (e || e === 0) {
inputProps.field.handleChange(Number(e) as any)
inputProps.handleChange(Number(e) as any)
} else {
inputProps.field.handleChange(undefined as any)
inputProps.handleChange(undefined as any)
}
}"
/>
Expand All @@ -77,7 +77,7 @@
:error="inputProps.error"
v-bind="$attrs"
:model-value="vuetifyValue"
@update:model-value="inputProps.field.handleChange"
@update:model-value="inputProps.handleChange"
>
<v-radio
v-for="option in inputProps.options"
Expand All @@ -101,8 +101,8 @@
:error="inputProps.error"
v-bind="$attrs"
:model-value="vuetifyValue"
@clear="inputProps.field.handleChange(undefined as any)"
@update:model-value="inputProps.field.handleChange"
@clear="inputProps.handleChange(undefined as any)"
@update:model-value="inputProps.handleChange"
/>

<v-autocomplete
Expand All @@ -120,8 +120,8 @@
:chips="inputProps.type === 'autocompletemultiple'"
v-bind="$attrs"
:model-value="vuetifyValue"
@clear="inputProps.field.handleChange(undefined as any)"
@update:model-value="inputProps.field.handleChange"
@clear="inputProps.handleChange(undefined as any)"
@update:model-value="inputProps.handleChange"
/>
</div>
</template>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
generic="From extends Record<PropertyKey, any>, Name extends DeepKeys<From>"
>
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"
Expand Down Expand Up @@ -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<From, Name>["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)
Expand Down Expand Up @@ -153,6 +157,7 @@ const inputProps: ComputedRef<InputProps<From, Name>> = 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,
Expand Down
8 changes: 8 additions & 0 deletions packages/vue-components/stories/OmegaForm.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -183,3 +184,10 @@ export const ProgrammaticallyHandleSubmitCheckErrors: Story = {
template: "<ProgrammaticallyHandleSubmitCheckErrorsComponent />"
})
}

export const Null: Story = {
render: () => ({
components: { NullComponent },
template: "<NullComponent />"
})
}
28 changes: 20 additions & 8 deletions packages/vue-components/stories/OmegaForm/ComplexForm.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<template>
<exampleForm.Form>
<div>isDirty: {{ isDirty }}</div>
<exampleForm.Input
label="aString"
name="aString"
Expand All @@ -13,8 +14,8 @@
name="aStringMin2Max4"
/>
<exampleForm.Input
label="aStringMin2Max3Nullable"
name="aStringMin2Max3Nullable"
label="aStringMin2Max3Optional"
name="aStringMin2Max3Optional"
/>
<exampleForm.Input
label="aNumber"
Expand Down Expand Up @@ -44,15 +45,15 @@
{ title: 'c', value: 'c' }
]"
/>
<exampleForm.Input
<!-- <exampleForm.Input
label="aMultiple"
name="aMultiple"
type="autocomplete"
:options="[
{ title: 'a', value: 'a' },
{ title: 'b', value: 'b' }
]"
/>
/> -->
<button>Submit</button>
<button
type="reset"
Expand All @@ -72,32 +73,38 @@

<script setup lang="ts">
import { S } from "effect-app"
import { watch } from "vue"
import { useOmegaForm } from "../../src/components/OmegaForm"

const exampleForm = useOmegaForm(
S.Struct({
aString: S.String,
aStringMin2: S.String.pipe(S.minLength(2)),
aStringMin2Max4: S.String.pipe(S.minLength(2)).pipe(S.maxLength(4)),
aStringMin2Max3Nullable: S.UndefinedOr(
aStringMin2Max3Optional: S.UndefinedOr(
S.String.pipe(S.minLength(2)).pipe(S.maxLength(3))
),
aNumber: S.Number,
aNumberMin2: S.Number.pipe(S.greaterThan(2)),
aNumberMin2Max: S.Number.pipe(S.greaterThan(2)).pipe(S.lessThan(4)),
aNumberMin2Max4Nullable: S.NullOr(S.Number.pipe(S.between(2, 4))),
aSelect: S.Union(S.Literal("a"), S.Literal("b"), S.Literal("c")),
aMultiple: S.Array(S.String)
aSelect: S.Union(S.Literal("a"), S.Literal("b"), S.Literal("c"))
// currently broken, also on main.
// aMultiple: S.Array(S.String)
}),
{
defaultValues: {
aString: "",
aNumberMin2Max4Nullable: null
},
onSubmit: async ({
value
}: {
value: {
aString: string
aStringMin2: string
aStringMin2Max4: string
aStringMin2Max3Nullable?: string
aStringMin2Max3Optional?: string
aNumber: number
aNumberMin2: number
aNumberMin2Max: number
Expand All @@ -115,4 +122,9 @@ const exampleForm = useOmegaForm(
}
}
)
const isDirty = exampleForm.useStore((_) => _.isDirty)
const values = exampleForm.useStore((_) => _.values)
watch(values, (v) => {
console.log("values changed", v)
})
</script>
77 changes: 77 additions & 0 deletions packages/vue-components/stories/OmegaForm/Null.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<template>
<exampleForm.Form>
<div>isDirty: {{ isDirty }}</div>
<exampleForm.Input
label="aString"
name="aString"
/>
<exampleForm.Input
label="aStringMin2Max3Optional"
name="aStringMin2Max3Optional"
/>
<exampleForm.Input
label="aNumberMin2Max4Nullable"
name="aNumberMin2Max4Nullable"
/>
<button>Submit</button>
<button
type="reset"
@click.prevent="exampleForm.clear()"
>
Clear
</button>
<button
type="button"
@click="exampleForm.reset()"
>
Reset
</button>
<exampleForm.Errors />
</exampleForm.Form>
</template>

<script setup lang="ts">
import { S } from "effect-app"
import { watch } from "vue"
import { useOmegaForm } from "../../src/components/OmegaForm"

const exampleForm = useOmegaForm(
S.Struct({
aString: S.String,
aStringMin2Max3Optional: S.UndefinedOr(
S.String.pipe(S.minLength(2)).pipe(S.maxLength(3))
),
aNumberMin2Max4Nullable: S.NullOr(S.Number.pipe(S.between(2, 4)))
}),
{
// we should really be setting default values to do all correctly
// TODO: we just generate it from the schema..
defaultValues: {
aString: "",
aNumberMin2Max4Nullable: null
},
onSubmit: async ({
value
}: {
value: {
aString: string
aStringMin2Max3Optional?: string
aNumberMin2Max4Nullable: number | null
}
}) => {
console.log(value)
}
},
{
persistency: {
policies: ["local"],
overrideDefaultValues: true
}
}
)
const isDirty = exampleForm.useStore((_) => _.isDirty)
const values = exampleForm.useStore((_) => _.values)
watch(values, (v) => {
console.log("values changed", v)
})
</script>
6 changes: 3 additions & 3 deletions packages/vue-components/stories/OmegaForm/SimpleForm.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<form.Form :subscribe="['values']">
<template #default="{ subscribedValues: { values } }">
<div>values: {{ values }}</div>
<form.Form :subscribe="['values', 'isDirty']">
<template #default="{ subscribedValues: { values, isDirty } }">
<div>values: {{ values }} {{ isDirty }}</div>
<form.Input
label="asder2"
name="asder2"
Expand Down
Loading
Loading