useValidation
A composable for handling validation of form fields. The useValidation
composable requires the form field values to be maintained outside of useValidation
, which makes it easy to add validation to pre-existing forms.
<script lang="ts" setup>
import { computed, Ref, ref } from 'vue';
import Box from '../../../src/components/Box/Box.vue';
import Button from '../../../src/components/Button/Button.vue';
import Checkbox from '../../../src/components/Checkbox/Checkbox.vue';
import Input from '../../../src/components/Input/Input.vue';
import Select from '../../../src/components/Select/Select.vue';
import Textarea from '../../../src/components/Textarea/Textarea.vue';
import minValue from '../../../src/composables/useValidation/ruleFactories/minValue';
import pattern from '../../../src/composables/useValidation/ruleFactories/pattern';
import required from '../../../src/composables/useValidation/ruleFactories/required';
import useValidation, { ValidationRules } from '../../../src/composables/useValidation/useValidation';
interface TimezoneOption {
id: string;
name: string;
}
interface FormState {
productName?: string;
quantity?: number;
sku?: string;
timezone?: TimezoneOption;
description?: string;
termsAndConditionsAgreement: boolean;
}
function getInitialFormState(): FormState {
return {
productName: undefined,
quantity: undefined,
sku: undefined,
timezone: undefined,
description: undefined,
termsAndConditionsAgreement: false,
};
}
const formState: Ref<FormState> = ref(getInitialFormState());
const blacklist = ref(['Untested Kush']);
const mockTimezones = computed<TimezoneOption[]>(() => [
{ id: 'new_york', name: 'America/New_York' },
{ id: 'chicago', name: 'America/Chicago' },
]);
const validationRules: ValidationRules<FormState> = {
productName: [
required(),
{
name: 'blacklist',
validator(value) {
if (!value) {
return true;
}
return !blacklist.value.includes(value);
},
message: 'Product is blacklisted',
},
],
quantity: [minValue({ min: 0 })],
sku: [pattern({ regex: /^[A-Z]{3}\d{3}$/, message: 'Format: ABC123' })],
timezone: [required()],
description: [required()],
termsAndConditionsAgreement: [
{
name: 'required',
validator(value) {
return value === true;
},
message: 'Please read and agree to the terms and conditions',
},
],
};
const validation = useValidation({ rules: validationRules, values: formState });
async function onSubmit() {
await validation.validate();
if (validation.hasErrors) {
return;
}
const requestBody = {
productName: formState.value.productName?.trim(),
quantity: String(formState.value.quantity),
sku: formState.value.sku,
timezone: formState.value.timezone?.id,
description: formState.value.description?.trim(),
termsAndConditionsAgreement: formState.value.termsAndConditionsAgreement ? 'yes' : 'no',
};
alert(
`Form submitted! \n\n${Object.entries(requestBody)
.map(([k, v]) => `${k}: ${v}`)
.join('\n')}\n\nThe form values will now reset.`,
);
formState.value = getInitialFormState();
validation.setAllUntouched();
}
</script>
<template>
<Box>
<div class="tw-grid tw-gap-x-4 tw-gap-y-0">
<div class="tw-col-span-12 md:tw-col-span-6">
<Input
v-model="formState.productName"
add-bottom-space
:error-text="validation.getError('productName')"
hint-text='Try "Untested Kush"'
label="Product Name"
@blur="validation.touch('productName')"
/>
</div>
<div class="tw-col-span-12 md:tw-col-span-6">
<Input
v-model="formState.quantity"
add-bottom-space
show-optional-in-label
:error-text="validation.getError('quantity')"
label="Quantity"
type="number"
@blur="validation.touch('quantity')"
/>
</div>
<div class="tw-col-span-12 md:tw-col-span-6">
<Input
v-model="formState.sku"
add-bottom-space
show-optional-in-label
label="SKU"
:error-text="validation.getError('sku')"
@blur="validation.touch('sku')"
/>
</div>
<div class="tw-col-span-12 md:tw-col-span-6">
<Select
v-model="formState.timezone"
prevent-empty
single
add-bottom-space
:error-text="validation.getError('timezone')"
label="Timezone"
:options="mockTimezones"
@closed="validation.touch('timezone')"
/>
</div>
<div class="tw-col-span-12">
<Textarea
v-model="formState.description"
add-bottom-space
label="Description"
:error-text="validation.getError('description')"
@blur="validation.touch('description')"
/>
</div>
<div class="tw-col-span-12 tw-mb-6 tw-mt-8">
<Checkbox
v-model:checked="formState.termsAndConditionsAgreement"
label="I have read and agree to the terms and conditions"
:error-text="validation.getError('termsAndConditionsAgreement')"
@update:checked="validation.touch('termsAndConditionsAgreement')"
/>
</div>
<div class="tw-col-span-12 tw-mt-4 tw-text-right">
<Button @click="onSubmit"> Submit </Button>
</div>
</div>
<div class="tw-mt-6">
<div>hasErrors: {{ validation.hasErrors }}</div>
<div>someTouched: {{ validation.someTouched }}</div>
<div>dirtyFields count: {{ validation.dirtyFields.length }}</div>
</div>
</Box>
</template>
Usage
import useValidation, { minValue, required } from '@leaflink/stash/useValidation';
interface FormState {
productName?: string;
quantity?: number;
}
function getInitialFormState(): FormState {
return {
productName: undefined,
quantity: undefined,
};
}
const formState = ref<FormState>(getInitialFormState());
const validationRules: ValidationRules<FormState> = {
productName: [required()],
quantity: [required(), minValue({ min: 0 })],
};
const validation = useValidation({ rules: validationRules, values: formState });
<Input
v-model="formState.productName"
:error-text="validation.getError('productName')"
@blur="validation.touch('productName')" <!-- Validations will not run until touch() is called -->
/>
<Input
v-model="formState.quantity"
:error-text="validation.getError('quantity')"
type="number"
@blur="validation.touch('quantity')" <!-- Validations will not run until touch() is called -->
/>
Click the "Show Code" button underneath the demo (above) to view a complete example.
Resetting a form
When a form needs to be reset so that a user can fill it out again, you must do 2 things:
- reset the form values
- reset the validation fields to be untouched; errors only appear for fields whose isTouched property is
true
- call
validation.setAllUntouched()
to set the isTouched property tofalse
for all fields - alternatively, you can individually call
validation.fields[fieldName].setTouched(false)
- call
const validation = useValidation({ rules: validationRules, values: formState });
async function onSubmit() {
await validation.validate();
if (validation.hasErrors) {
return;
}
await submitForm();
alert('Form submitted!');
formState.value = {
productName: undefined,
quantity: undefined,
};
validation.setAllUntouched();
}
Updating initial values
If you need to use the submitted field values as the new initial values (ex: when allowing the user to edit data multiple times), you can use validation.setInitialValues(newValues)
after calling validation.setAllUntouched()
:
const validation = useValidation({ rules: validationRules, values: formState });
async function onSubmit() {
await validation.validate();
if (validation.hasErrors) {
return;
}
const apiResponse = await updateData();
alert('Data updated!');
const newInitialState = {
productName: apiResponse.productName,
quantity: apiResponse.quantity,
};
validation.setAllUntouched();
validation.setInitialValues(newInitialState);
formState.value = newInitialState;
}
Custom validator
const validationRules: ValidationRules<FormState> = {
productName: [
required(),
// custom rule
{
name: 'blacklist',
validator(value) {
if (!value) {
return true;
}
return !blacklist.value.includes(value);
},
message: 'Product is blacklisted',
},
],
}
Custom error message
Each rule's message
can be either a string or a "getter" function that returns a string.
import { toValue } from 'vue';
const rules = {
productName: [
// with custom message string
required({ message: 'Product Name is Required' }),
],
quantity: [
minValue({ min: 0 }),
maxValue({
min: toValue(productsAvailable),
message: () => `Only ${toValue(productsAvailable)} available`, // custom message function
}),
],
};
WARNING
If the message
includes a reactive value (such as a computed property or a ref), then message
must use a getter function or else the message will only display the initial value of the reactive variable instead of its current value.
Rules in a different file
<script setup lang="ts">
import useValidation from 'composables/useValidation/useValidation';
import getMyValidationRules, { ValidationValues } from './getMyValidationRules';
interface FormState {
productName?: string;
quantity?: number;
}
const formState = ref<FormState>({});
const rules = getMyValidationRules(formState);
const validation = useValidation<ValidationValues>({ rules, values: formState });
</script>
// getMyValidationRules.ts
import { MaybeRefOrGetter, readonly, toValue } from 'vue';
export interface ValidationValues {
productName?: string;
quantity?: number;
}
export function getMyValidationRules(formState: MaybeRefOrGetter<ValidationValues>): Readonly<ValidationRules<ValidationValues>> {
return readonly({
productName: [required()],
quantity: [minValue({ min: 0 })],
});
}
Nested fields
<script lang="ts" setup>
import { ref } from 'vue';
import useValidation, { minValue, required } from '../../src/composables/useValidation/useValidation';
function getInitialFormState() {
return {
productName: undefined,
price: {
min: undefined,
max: undefined,
},
};
}
interface FormState {
productName?: string;
price: {
min?: number;
max?: number;
};
}
const formState: Ref<FormState> = ref(getInitialFormState());
// TODO: figure out how to allow nested ValidationRules in TypeScript
const validationRules: any = {
productName: [required()],
price: {
min: [minValue({ min: 0 })],
max: [minValue({ min: () => formState.value.price.min })],
},
};
const validation = useValidation({ rules: validationRules, values: formState });
</script>
<template>
<Input
v-model="formState.productName"
add-bottom-space
:error-text="validation.getError('productName')"
hint-text='Try "Untested Kush"'
label="Product Name"
@blur="validation.touch('productName')"
/>
<Input
v-model="formState.price.min"
add-bottom-space
:error-text="validation.getError('price.min')"
label="Price Min"
type="number"
@blur="validation.touch('price.min')"
/>
<Input
v-model="formState.price.max"
add-bottom-space
:error-text="validation.getError('price.max')"
label="Price Max"
type="number"
@blur="validation.touch('price.max')"
/>
</template>