logaretm / vee-validate

✅ Painless Vue forms
https://vee-validate.logaretm.com/v4
MIT License
10.75k stars 1.26k forks source link

Composable API: Better static typing support #3375

Closed tobiasdiez closed 1 year ago

tobiasdiez commented 3 years ago

Is your feature request related to a problem? Please describe.

Currently, there is no connection between the schema and the useField methods. Consider for example, the following code snippet taken from the documentation

    const schema = yup.object({
      email: yup.string().required().email(),
      password: yup.string().required().min(8),
    });

    useForm({ validationSchema: schema });

    const { value: email, errorMessage: emailError } = useField('email');
    const { value: password, errorMessage: passwordError } = useField('password');

If one renames email in the schema but forget to do this in the useField call, this is only noticed on runtime when the useField method is invoked. Moreover, in order to get proper type support for the email variable, one needs to use useField<string>('email') if I'm not mistaken. So the problem is that there is essentially no connection between the schema and the useField method.

Describe the solution you'd like

Add a useField method to PublicFormContext so that the example above can be written as

    const schema = yup.object({
      email: yup.string().required().email(),
      password: yup.string().required().min(8),
    });

    const form = useForm({ validationSchema: schema });

    const { value: email, errorMessage: emailError } = form.useField('email');
    const { value: password, errorMessage: passwordError } = form.useField('password');

Now, the useForm method can get enough typing information and pass this to form.useField. The upshot is that one gets type errors if the parameter of useField is not defined in the schema. At the same time, the type of the variable email could also automatically be inferred to be string, since this is its type in the schema.

numfin commented 3 years ago

why not form.email.useField or smth like that?

tobiasdiez commented 3 years ago

That's a nice suggestion, or even shorter const { value: email, errorMessage: emailError } = form.email without the useField at all.

ctrlplusb commented 3 years ago

Howdy all;

I also experienced some friction with Vue 3 compositional API and TypeScript experience around the current APIs. There seemed to be a fair bit of boilerplate and I didn't like the type casting via generics on useField. I also was getting invalid types on the callback handler for handleSubmit.

I ended up writing a userland utility to help with this.


Util

import { toFormValidator } from "@vee-validate/zod";
import { SubmissionContext, useField, useForm } from "vee-validate";
import { reactive, Ref } from "vue";
import * as zod from "zod";

type MaybeRef<T> = Ref<T> | T;

// I had to reimpliment this interface as it is not exported by vee-validate
interface FieldOptions<TValue = unknown> {
  initialValue?: MaybeRef<TValue>;
  validateOnValueUpdate: boolean;
  validateOnMount?: boolean;
  bails?: boolean;
  type?: string;
  valueProp?: MaybeRef<TValue>;
  checkedValue?: MaybeRef<TValue>;
  uncheckedValue?: MaybeRef<TValue>;
  label?: MaybeRef<string | undefined>;
  standalone?: boolean;
}

// I had to reimpliment this interface as it is not exported by vee-validate
interface FormOptions<TValues extends Record<string, any>> {
  initialValues?: MaybeRef<TValues>;
  initialErrors?: Record<keyof TValues, string | undefined>;
  initialTouched?: Record<keyof TValues, boolean>;
  validateOnMount?: boolean;
}

export const useZodForm = <
  Schema extends zod.ZodObject<any> = any,
  Values extends zod.infer<Schema> = any,
>(
  schema: Schema,
  options: Omit<FormOptions<Values>, "validationSchema"> = {},
) => {
  const validationSchema = toFormValidator(schema);
  const form = useForm<Values>({
    ...options,
    validationSchema: validationSchema as any,
  });
  return {
    ...form,
    useField: <Field extends keyof Values, FieldType extends Values[Field]>(
      field: Field,
      opts?: FieldOptions<FieldType>,
    ) => reactive(useField<FieldType>(field as string, undefined, opts)),
    handleSubmit: (
      cb: (values: Values, ctx: SubmissionContext<Values>) => unknown,
    ) => form.handleSubmit(cb),
  };
};

Note how I return a wrapped and typed version of useField, and also a wrapped and typed version of handleSubmit. I additionally wrap the useField result with reactive so I can just return out the entire value rather than having to destructure and rename value and errorMessage for each field.

You can also still pass in all the options to useField and get all the other properties expected from the result of useField and useForm.


Examples

Usage with just useZodForm

export default defineComponent({
  name: "RegisterForm",
  setup() {
     const { handleSubmit, errors, values } = useZodForm(
      zod.object({
        displayName: zod
          .string()
          .nonempty({ message: "Display name is required" }),
        email: zod
          .string()
          .email({ message: "Must be a valid email" })
          .min(1, "Email name is required"),
        password: zod.string().min(1, "Password is required"),
      }),
    );

    const onSubmit = handleSubmit(async (values) => {
      console.log(values);
    });

    return {
      errors,
      values,
      onSubmit,
    };
  },
});

Usage with useZodForm and useField

export default defineComponent({
  name: "RegisterForm",
  setup() {
    const { handleSubmit, useField } = useZodForm(
      zod.object({
        email: zod
          .string()
          .min(1)
          .email({ message: "Must be a valid email" }),
        password: zod.string().min(1),
      }),
    );

    const email = useField("email");
    const password = useField("password");

    const onSubmit = handleSubmit(async (values) => {
      console.log(values);
    });

    return {
      email,
      onSubmit,
      password,
    };
  },
});

Typing Demo

Fields:

Screenshot 2021-09-01 at 00 19 18

Handle submit callback:

Screenshot 2021-09-01 at 00 14 55

Render function data:

Screenshot 2021-09-01 at 00 43 43
letstri commented 2 years ago

Or you can use my variant of zod:

export const useZodForm = <Schema extends zod.ZodObject<AnyType>, Values extends zod.infer<Schema>>(
  schema: Schema,
  initialValues: Values
) => {
  const form = useForm({
    initialValues,
    validationSchema: toFormValidator(schema),
  });

  const keys = Object.keys(form.values) as Array<keyof typeof form.values>;

  const getField = <Field extends keyof typeof form.values>(field: Field) =>
    computed({
      get() {
        return form.values[field];
      },

      set(value: typeof form.values[Field]) {
        form.setFieldValue(field, value);
      },
    });

  const fields = keys.reduce(
    (acc, key) => ({
      ...acc,
      [key]: getField(key),
    }),
    {} as { [key in keyof typeof form.values]: WritableComputedRef<typeof form.values[key]> }
  );

  return { form, fields };
};

and use it like this:

<template>
  <input v-model="fields.firstName" />
  <input v-model="fields.lastName" />
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import * as z from 'zod';
import { useZodForm } from '@/shared/hooks';

export default defineComponent({
  setup() {
    const { form, fields } = useZodForm(
      z.object({
        firstName: z.string(),
        lastName: z.string(),
      }),
      { firstName: '', lastName: '' }
    );

    form.handleSubmit(values => {
      console.log(values);
    });

    return { fields };
  },
});
</script>
filipbekic01 commented 2 years ago

Connection between form and useField method is more than welcome.

letstri commented 2 years ago

For yup I'm using this hook:

(need to upgrade yup to beta version)

import { Ref } from 'vue';
import { useField, useForm } from 'vee-validate';
import { ObjectSchema, InferType } from 'yup';

interface Form<Schema, Values> {
  validationSchema: Schema;
  initialValues: Values;
}

type Fields<F> = { [K in keyof F]: Ref<F[K]> };

export const useYupForm = <Schema extends ObjectSchema<any>, Values extends InferType<Schema>>({
  initialValues,
  validationSchema,
}: Form<Schema, Values>) => {
  const form = useForm({
    initialValues,
    validationSchema,
  });

  const keys = Object.keys(form.values) as (keyof Values)[];

  const fields = keys.reduce(
    (acc, key) => ({
      ...acc,
      [key]: useField(key).value,
    }),
    {} as Fields<Values>
  );

  return { form, fields };
};
CapitaineToinon commented 1 year ago

Up. I really don't understand why this lib has support for zod if it's to get no types out of validation out of the box. Are the above workarounds the only solutions as of now?

filipbekic01 commented 1 year ago

For yup I'm using this hook:

(need to upgrade yup to beta version)

import { Ref } from 'vue';
import { useField, useForm } from 'vee-validate';
import { ObjectSchema, InferType } from 'yup';

interface Form<Schema, Values> {
  validationSchema: Schema;
  initialValues: Values;
}

type Fields<F> = { [K in keyof F]: Ref<F[K]> };

export const useYupForm = <Schema extends ObjectSchema<any>, Values extends InferType<Schema>>({
  initialValues,
  validationSchema,
}: Form<Schema, Values>) => {
  const form = useForm({
    initialValues,
    validationSchema,
  });

  const keys = Object.keys(form.values) as (keyof Values)[];

  const fields = keys.reduce(
    (acc, key) => ({
      ...acc,
      [key]: useField(key).value,
    }),
    {} as Fields<Values>
  );

  return { form, fields };
};

How would you return other field attributes instead of value only? For example I'd need field meta too. Better yet, get typed useField method as return value instead of fields.

logaretm commented 1 year ago

I really don't understand why this lib has support for zod if it's to get no types out of validation out of the box

Previously, vee-validate was able to infer the form values type from the validation schema however it wasn't perfect because:

If everybody was using yup or zod then maybe. But that's just not the case. I'm trying to find the time to re-explore this in a way that works for both type of users.

jayli3n commented 1 year ago

For yup I'm using this hook:

(need to upgrade yup to beta version)

import { Ref } from 'vue';
import { useField, useForm } from 'vee-validate';
import { ObjectSchema, InferType } from 'yup';

interface Form<Schema, Values> {
  validationSchema: Schema;
  initialValues: Values;
}

type Fields<F> = { [K in keyof F]: Ref<F[K]> };

export const useYupForm = <Schema extends ObjectSchema<any>, Values extends InferType<Schema>>({
  initialValues,
  validationSchema,
}: Form<Schema, Values>) => {
  const form = useForm({
    initialValues,
    validationSchema,
  });

  const keys = Object.keys(form.values) as (keyof Values)[];

  const fields = keys.reduce(
    (acc, key) => ({
      ...acc,
      [key]: useField(key).value,
    }),
    {} as Fields<Values>
  );

  return { form, fields };
};

This is nice, but doesnt seem to work with nested objects inside your schema :/

logaretm commented 1 year ago

4.8 is released with automatic schema inference for input/output type with either yup or zod. Check this for more information

tobiasdiez commented 1 year ago

This works very well, thanks!

ScreamZ commented 6 months ago

Looks like what I use with react-hook-form but what about nested field autocompletion ?

So far in react, I'm able to do:

const { register, control, handleSubmit, control, formState: { dirtyFields, errors } } = useFormWithSchemaBuilder(
    (yup) =>
      yup.object({
        firstname: yup.string().required("some message"),
      })
  );

// Render with register
 <CustomInput
                register={register("firstname")}
                inputType="text"
                placeholder={t("contact_form.firstname_placeholder")}
                containLabel={{ label: t("contact_form.firstname_placeholder"), labelTransition: true }}
              />

// Render with controls (for controlled components)

<InputText
          control={control}
          name="firstname" // I get auto-completion on "firstName"
          textContentType="emailAddress"
          keyboardType="email-address"
          autoComplete="email"
          error={errors.firstname?.message}
          autoCorrect={false}
          autoCapitalize="none"
        />
import { AppText } from "@modules/ui/components/Text";
import React, { RefObject } from "react";
import { Control, Controller, Path } from "react-hook-form";
import { TextInput, View, TextInputProps } from "react-native";
import { useTailwind } from "tailwind-rn";

interface BaseInputProps {
  label?: string;
  rightIcon?: React.ReactNode;
  error?: string;
}

interface ActiveInputProps<T> extends BaseInputProps {
  control: Control<T>;
  name: Path<T>;
  inputRef?: RefObject<TextInput>;
}

interface DisabeldInputProps extends BaseInputProps {
  value: string;
}

type Props<T> = ActiveInputProps<T> | DisabeldInputProps;

export function InputText<T>(
  props: Props<T> &
    Pick<
      TextInputProps,
      | "autoComplete"
      | "keyboardAppearance"
      | "keyboardType"
      | "textContentType"
      | "secureTextEntry"
      | "passwordRules"
      | "autoCapitalize"
      | "autoCorrect"
      | "placeholder"
      | "onSubmitEditing"
      | "returnKeyType"
    >,
) {
  const tw = useTailwind();

  const { label, rightIcon, error } = props;

  // Readonly field
  if ("value" in props) {
    return (
      <>
        {label && (
          <AppText style={tw("text-text-strong mb-2")}>{label}</AppText>
        )}
        <View
          style={tw(
            "flex flex-row items-center rounded-lg border border-surface-strong bg-surface-base",
          )}
        >
          <TextInput
            style={tw("h-12 flex-grow px-4 py-2.5 text-text-soft font-Medium")}
            value={props.value}
            editable={false}
          />

          {rightIcon && <View style={tw("mx-4")}>{rightIcon}</View>}
        </View>
        {error && (
          <AppText size="sm" style={tw("text-danger-base mt-1")}>
            {error}
          </AppText>
        )}
      </>
    );
  }

  const { placeholder, control, name, inputRef, ...rest } = props;

  return (
    <>
      {label && <AppText style={tw("text-text-strong mb-2")}>{label}</AppText>}
      <View
        style={tw(
          "flex flex-row items-center rounded-lg border border-surface-strong bg-surface-base",
        )}
      >
        <Controller
          control={control}
          render={({ field: { onChange, onBlur, value } }) => (
            <TextInput
              style={tw("h-12 flex-grow px-4 py-2.5 text-text-soft")}
              onBlur={onBlur}
              onChangeText={onChange}
              value={value as string}
              placeholder={placeholder}
              ref={inputRef}
              {...rest}
            />
          )}
          name={name}
        />
        {rightIcon && <View style={tw("mx-4")}>{rightIcon}</View>}
      </View>
      {error && (
        <AppText size="sm" style={tw("text-danger-base mt-1")}>
          {error}
        </AppText>
      )}
    </>
  );
}

This is possible because we have

  control: Control<T>;
  name: Path<T>;

types exported, how to achieve same with vee ?