diff --git a/docs/content/api/use-form.md b/docs/content/api/use-form.md index 332f04dea..57c59f0c7 100644 --- a/docs/content/api/use-form.md +++ b/docs/content/api/use-form.md @@ -28,6 +28,8 @@ export default { }; ``` +The `useForm` composable has very powerful type information and can be fully typed, for more information check the [useForm typing tutorial](/tutorials/use-form-types) + ## API Reference The full signature of the `useForm` function looks like this: @@ -156,23 +158,26 @@ values.value; // { email: 'something@gmail.com', .... } -`setFieldError: (field: string, message: string) => void` +`setFieldError: (field: string, message: string | undefined) => void` -Sets a field's error message, useful for setting messages form an API or that are not available as a validation rule. +Sets a field's error message, useful for setting messages form an API or that are not available as a validation rule. Setting the message to `undefined` or an empty string clears the errors and marks the field as valid. ```js const { setFieldError } = useForm(); setFieldError('email', 'this email is already taken'); + +// Mark field as valid +setFieldError('email', undefined); ``` If you try to set an error for field doesn't exist, it will not affect the form's overall validity and will be ignored. -`setErrors: (fields: Record) => void` +`setErrors: (fields: Record) => void` @@ -184,9 +189,16 @@ const { setErrors } = useForm(); setErrors({ email: 'this email is already taken', password: 'someone already has this password 🤪', + firstName: undefined, // clears errors and marks the field as valid }); ``` + + +Any missing fields you didn't pass to `setErrors` will be unaffected and their state will not change + + + `setFieldValue: (field: string, value: any) => void` diff --git a/docs/content/guide/use-form-types.md b/docs/content/guide/use-form-types.md new file mode 100644 index 000000000..098a2e6f8 --- /dev/null +++ b/docs/content/guide/use-form-types.md @@ -0,0 +1,73 @@ +--- +title: useForm() Field Types +description: Using field types with useForm +order: 7 +--- + +# useForm() Field Types + +The `useForm` function exposed by vee-validate has field typing capabilities if you need it, getting type information for your fields and their values can be very powerful when building complex forms. + +By unlocking the field type you automatically get more strict information for the various properties/methods exposed by `useForm` like `setErrors` and `setTouched`. For more information about these properties/methods go to the [`useForm()` API reference](/api/use-form). + +## Unlocking Field Types + +There are two ways you can get the advanced typing information for your fields, the first is to provide a generic type to `useForm`. + +```ts +import { useForm } from 'vee-validate'; + +interface LoginForm { + email: string; + password: string; +} + +// in your setup +const { errors } = useForm(); +``` + +```ts +import { useForm } from 'vee-validate'; + +interface LoginForm { + email: string; + password: string; +} + +// in your setup +const { errors, setErrors, setFieldValue } = useForm(); + +errors.value; // typed as { email?: string; password?: string } + +setErrors({ + email: 'This field is invalid', // auto-complete for `email` and `password` +}); + +setFieldValue('email', 'example@gmail.com'); // auto-complete for the field name and its value type +``` + +For example if you were to do this in the previous example: + +```ts +setFieldValue('age', 5); // ⛔️ TypeScript error +setFieldValue('email', 5); // ⛔️ TypeScript error +``` + +It will error out because `age` is not defined in the `LoginForm` type you defined. The second line errors out because the `email` field is typed as a `string`. + +## With Initial Values + +You can also unlock the same capabilities for simpler fields by providing an `initialValues` property to `useForm`: + +```typescript +import { useForm } from 'vee-validate'; + +const { errors, setErrors, setFieldValue } = useForm({ + initialValues: { + email: '', + password: '', + }, +}); +``` + +`useForm` will automatically pick up the type of `initialValues` and use it for the field types. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 8d79269e2..a6959ef90 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -30,24 +30,30 @@ export type SubmitEvent = Event & { target: HTMLFormElement }; export type GenericValidateFunction = (value: any) => boolean | string | Promise; -export interface FormContext { +export interface FormContext = Record> { register(field: any): void; unregister(field: any): void; - values: Record; - fields: ComputedRef>; - schema?: Record> | ObjectSchema; - validateSchema?: (shouldMutate?: boolean) => Promise>; - setFieldValue: (path: string, value: any) => void; - setFieldError: (field: string, message: string) => void; - setErrors: (fields: Record) => void; - setValues: (fields: Record) => void; - setFieldTouched: (field: string, isTouched: boolean) => void; - setTouched: (fields: Record) => void; - setFieldDirty: (field: string, isDirty: boolean) => void; - setDirty: (fields: Record) => void; + values: TValues; + fields: ComputedRef>; + schema?: Record> | ObjectSchema; + validateSchema?: (shouldMutate?: boolean) => Promise>; + setFieldValue(field: T, value: TValues[T]): void; + setFieldError: (field: keyof TValues, message: string | undefined) => void; + setErrors: (fields: Partial>) => void; + setValues(fields: Partial>): void; + setFieldTouched: (field: keyof TValues, isTouched: boolean) => void; + setTouched: (fields: Partial>) => void; + setFieldDirty: (field: keyof TValues, isDirty: boolean) => void; + setDirty: (fields: Partial>) => void; reset: () => void; } -type SubmissionContext = { evt: SubmitEvent; form: FormContext }; +type SubmissionContext = Record> = { + evt: SubmitEvent; + form: FormContext; +}; -export type SubmissionHandler = (values: Record, ctx: SubmissionContext) => any; +export type SubmissionHandler = Record> = ( + values: TValues, + ctx: SubmissionContext +) => any; diff --git a/packages/core/src/useForm.ts b/packages/core/src/useForm.ts index 3dc006f0d..2208b4d80 100644 --- a/packages/core/src/useForm.ts +++ b/packages/core/src/useForm.ts @@ -1,4 +1,4 @@ -import { computed, ref, Ref, provide, reactive, onMounted, isRef, watch, ComputedRef, unref } from 'vue'; +import { computed, ref, Ref, provide, reactive, onMounted, isRef, watch, unref } from 'vue'; import type { ObjectSchema, ValidationError } from 'yup'; import type { useField } from './useField'; import { @@ -13,15 +13,17 @@ import { import { getFromPath, isYupValidator, keysOf, setInPath, unsetPath } from './utils'; import { FormErrorsSymbol, FormInitialValues, FormSymbol } from './symbols'; -interface FormOptions { - validationSchema?: Record> | ObjectSchema; - initialValues?: MaybeReactive>; +interface FormOptions> { + validationSchema?: + | Record> + | ObjectSchema; + initialValues?: MaybeReactive; validateOnMount?: boolean; } type FieldComposite = ReturnType; -export function useForm(opts?: FormOptions) { +export function useForm = Record>(opts?: FormOptions) { // A flat array containing field references const fields: Ref = ref([]); @@ -29,7 +31,7 @@ export function useForm(opts?: FormOptions) { const isSubmitting = ref(false); // a field map object useful for faster access of fields - const fieldsById = computed(() => { + const fieldsById = computed>(() => { return fields.value.reduce((acc, field) => { // if the field was not added before if (!acc[field.name]) { @@ -59,11 +61,11 @@ export function useForm(opts?: FormOptions) { }); // a private ref for all form values - const formValues = reactive>({}); + const formValues = reactive({}) as TValues; // an aggregation of field errors in a map object - const errors = computed(() => { - return activeFields.value.reduce((acc: Record, field) => { + const errors = computed<{ [P in keyof TValues]?: string }>(() => { + return activeFields.value.reduce((acc: Record, field) => { // Check if its a grouped field (checkbox/radio) let message: string | undefined; if (Array.isArray(fieldsById.value[field.name])) { @@ -74,7 +76,7 @@ export function useForm(opts?: FormOptions) { } if (message) { - acc[field.name] = message; + acc[field.name as keyof TValues] = message; } return acc; @@ -82,8 +84,8 @@ export function useForm(opts?: FormOptions) { }); // same as form values but filtered disabled fields out - const activeFormValues = computed(() => { - return activeFields.value.reduce((formData: Record, field) => { + const activeFormValues = computed(() => { + return activeFields.value.reduce((formData: Record, field) => { setInPath(formData, field.name, unref(field.value)); return formData; @@ -91,7 +93,7 @@ export function useForm(opts?: FormOptions) { }); // initial form values - const { initialValues } = useFormInitialValues(fieldsById, formValues, opts?.initialValues); + const { initialValues } = useFormInitialValues(fieldsById, formValues, opts?.initialValues); // form meta aggregations const meta = useFormMeta(fields, initialValues); @@ -99,7 +101,7 @@ export function useForm(opts?: FormOptions) { /** * Manually sets an error message on a specific field */ - function setFieldError(field: string, message: string) { + function setFieldError(field: keyof TValues, message: string | undefined) { const fieldInstance = fieldsById.value[field]; if (!fieldInstance) { return; @@ -107,19 +109,19 @@ export function useForm(opts?: FormOptions) { if (Array.isArray(fieldInstance)) { fieldInstance.forEach(instance => { - instance.setValidationState({ errors: [message] }); + instance.setValidationState({ errors: message ? [message] : [] }); }); return; } - fieldInstance.setValidationState({ errors: [message] }); + fieldInstance.setValidationState({ errors: message ? [message] : [] }); } /** * Sets errors for the fields specified in the object */ - function setErrors(fields: Record) { - Object.keys(fields).forEach(field => { + function setErrors(fields: Partial>) { + keysOf(fields).forEach(field => { setFieldError(field, fields[field]); }); } @@ -127,33 +129,33 @@ export function useForm(opts?: FormOptions) { /** * Sets a single field value */ - function setFieldValue(path: string, value: any) { - const field = fieldsById.value[path]; + function setFieldValue(field: T, value: TValues[T] | undefined) { + const fieldInstance = fieldsById.value[field] as any; // Multiple checkboxes, and only one of them got updated - if (Array.isArray(field) && field[0]?.type === 'checkbox' && !Array.isArray(value)) { - const oldVal = getFromPath(formValues, path); + if (Array.isArray(fieldInstance) && fieldInstance[0]?.type === 'checkbox' && !Array.isArray(value)) { + const oldVal = getFromPath(formValues, field as string); const newVal = Array.isArray(oldVal) ? [...oldVal] : []; const idx = newVal.indexOf(value); idx >= 0 ? newVal.splice(idx, 1) : newVal.push(value); - setInPath(formValues, path, newVal); + setInPath(formValues, field as string, newVal); return; } let newValue = value; // Single Checkbox - if (field?.type === 'checkbox') { - newValue = getFromPath(formValues, path) === value ? undefined : value; + if (fieldInstance?.type === 'checkbox') { + newValue = getFromPath(formValues, field as string) === value ? undefined : value; } - setInPath(formValues, path, newValue); + setInPath(formValues, field as string, newValue); } /** * Sets multiple fields values */ - function setValues(fields: Record) { - Object.keys(fields).forEach(field => { + function setValues(fields: Partial) { + keysOf(fields).forEach(field => { setFieldValue(field, fields[field]); }); } @@ -161,7 +163,7 @@ export function useForm(opts?: FormOptions) { /** * Sets the touched meta state on a field */ - function setFieldTouched(field: string, isTouched: boolean) { + function setFieldTouched(field: keyof TValues, isTouched: boolean) { const fieldInstance = fieldsById.value[field]; if (!fieldInstance) { return; @@ -178,16 +180,16 @@ export function useForm(opts?: FormOptions) { /** * Sets the touched meta state on multiple fields */ - function setTouched(fields: Record) { - Object.keys(fields).forEach(field => { - setFieldTouched(field, fields[field]); + function setTouched(fields: Partial>) { + keysOf(fields).forEach(field => { + setFieldTouched(field, !!fields[field]); }); } /** * Sets the dirty meta state on a field */ - function setFieldDirty(field: string, isDirty: boolean) { + function setFieldDirty(field: keyof TValues, isDirty: boolean) { const fieldInstance = fieldsById.value[field]; if (!fieldInstance) { return; @@ -204,9 +206,9 @@ export function useForm(opts?: FormOptions) { /** * Sets the dirty meta state on multiple fields */ - function setDirty(fields: Record) { - Object.keys(fields).forEach(field => { - setFieldDirty(field, fields[field]); + function setDirty(fields: Partial>) { + keysOf(fields).forEach(field => { + setFieldDirty(field, !!fields[field]); }); } @@ -257,7 +259,7 @@ export function useForm(opts?: FormOptions) { unsetPath(formValues, fieldName); } - const formCtx: FormContext = { + const formCtx: FormContext = { register: registerField, unregister: unregisterField, fields: fieldsById, @@ -295,7 +297,7 @@ export function useForm(opts?: FormOptions) { return results.every(r => !r.errors.length); }; - const handleSubmit = (fn?: SubmissionHandler) => { + const handleSubmit = (fn?: SubmissionHandler) => { return function submissionHandler(e: unknown) { if (e instanceof Event) { e.preventDefault(); @@ -364,10 +366,7 @@ export function useForm(opts?: FormOptions) { /** * Manages form meta aggregation */ -function useFormMeta( - fields: Ref, - initialValues: MaybeReactive> -): ComputedRef }> { +function useFormMeta(fields: Ref, initialValues: MaybeReactive) { const MERGE_STRATEGIES: Record, 'every' | 'some'> = { valid: 'every', dirty: 'some', @@ -384,13 +383,16 @@ function useFormMeta( }, {} as Record, boolean>); return { - initialValues: unref(initialValues), + initialValues: unref(initialValues) as TValues, ...flags, }; }); } -async function validateYupSchema(form: FormContext, shouldMutate = false): Promise> { +async function validateYupSchema( + form: FormContext, + shouldMutate = false +): Promise> { const errors: any[] = await (form.schema as any) .validate(form.values, { abortEarly: false }) .then(() => []) @@ -413,7 +415,7 @@ async function validateYupSchema(form: FormContext, shouldMutate = false): Promi }, {}); // Aggregates the validation result - const aggregatedResult = Object.keys(fields).reduce((result: Record, fieldId) => { + const aggregatedResult = keysOf(fields).reduce((result: Record, fieldId) => { const field = fields[fieldId]; const messages = (errorsByPath[fieldId] || { errors: [] }).errors; const fieldResult = { @@ -436,7 +438,7 @@ async function validateYupSchema(form: FormContext, shouldMutate = false): Promi field.setValidationState(fieldResult); return result; - }, {}); + }, {} as Record); return aggregatedResult; } @@ -444,17 +446,17 @@ async function validateYupSchema(form: FormContext, shouldMutate = false): Promi /** * Manages the initial values prop */ -function useFormInitialValues( - fields: Ref, - formValues: Record, - providedValues?: MaybeReactive> +function useFormInitialValues( + fields: Ref>, + formValues: TValues, + providedValues?: MaybeReactive ) { - const initialValues = computed>(() => { + const initialValues = computed(() => { if (isRef(providedValues)) { - return providedValues.value as Record; + return providedValues.value; } - return providedValues || {}; + return providedValues || ({} as TValues); }); // Watch initial values for changes, and update the pristine (non-dirty and non-touched fields) @@ -464,15 +466,15 @@ function useFormInitialValues( initialValues, value => { const isSafeToUpdate = (f: any) => f.meta.dirty || f.meta.touched; - Object.keys(fields.value).forEach(fieldPath => { + keysOf(fields.value).forEach(fieldPath => { const field = fields.value[fieldPath]; const isFieldDirty = Array.isArray(field) ? field.some(isSafeToUpdate) : isSafeToUpdate(field); if (isFieldDirty) { return; } - const newValue = getFromPath(value, fieldPath); - setInPath(formValues, fieldPath, newValue); + const newValue = getFromPath(value, fieldPath as string); + setInPath(formValues, fieldPath as string, newValue); }); }, {