The (al)most complete dynamic form with VeeValidate and Composition API
If you tried to incorporate validation to your forms, you've probably stumbled upon libraries like Vuelidate and VeeValidate - both very powerful libraries, but currently only VeeValidate supports 3rd version of Vue in stable version. Recently I have tried to make a dynamic form with validation, and I have encountered a number of problems that are tricky to solve.
Before diving in, let me list things that were required for my dynamic form:
- field validation
- nested fields support
- field array support
- schema update support
- custom input support
Real-world example
We are going to implement a survey for frontend developers. In the survey we're going to ask what frameworks a user has experience with and ask some questions for selected items including experience details.
The workflow will be as follows:
- user selects frameworks
- for each selected framework appropriate inputs appear
- for each selected framework button appears that create inputs for new job experience item
Here is form's image.
NOTE: for this example I'm going to use vite and Tailwind UI (hence the looks), but it is applicable for other toolings like vue-cli and nuxt, so don't mind the presence of Vue SFC file extensions in imports - vite requires that they are present. Also I'm going to use lodash functions, but you can use native functions or copy from lodash if you can't use it.
Schema format
Each field (NOT field array) in a schema should have:
id
- this will be used for recursive field componentname
- for vee-validate to initialize schemacomponent
- which component to renderlabel
- to show label for input (you can use it for placeholders or have separate field for it)rules
- validation rules (going to use yup here)initialValue
- initial value for inputoptions
- options for select component
For field array schema should have:
name
label
fields
- list of fields for new items each having the same schema listed above
So, in case of our example, the schema will be as following:
import { array } from 'yup';
const schema = {
frameworks: {
id: 1,
label: "Which frameworks do you have experience with?",
component: "CheckboxGroup",
name: "frameworks",
value: [],
rules: array()
.of(string())
.min(1, "Please, select one of frameworks"),
options: frameworks,
},
data: {},
}
data
is going to contain generated schema for each selected framework similar to following:
schema.data = {
vue: {
opinion: {
id: 2,
label: `What is your opinion on Vue?`,
component: "Textarea",
name: `data.vue.opinion`,
value: "",
rules: string().required("Field is required"),
},
experience: {
name: "data.experience",
label: "Past experience",
addLabel: "Add past experience",
fields: [
{
id: 3,
label: "Framework",
component: "Select",
name: "framework",
value: "vue",
options: pick(frameworks, values.frameworks),
},
{
id: 4,
label: "Title",
component: "TextInput",
name: "title",
value: "",
rules: string().required("Field is required"),
},
{
id: 5,
label: "Company",
component: "TextInput",
name: "company",
value: "",
rules: string().required("Field is required"),
},
{
id: 6,
label: "Start date (year)",
component: "TextInput",
name: "start",
value: "",
rules: string().required("Field is required"),
},
{
id: 7,
label: "End date (year)",
component: "TextInput",
name: "end",
value: "",
rules: string().required("Field is required"),
},
],
}
}
}
You can also add any fields you want.
Main page component
The main page should contain the schema mentioned above and listen to changes in form values. If the value of frameworks
field changes, it should add or remove fields based on value.
<Form @change="updateSchema" />
...
import { ref } from 'vue';
import { string } from 'yup';
import isEqual from 'lodash-es/isEqual';
import cloneDeep from 'lodash-es/cloneDeep';
import forEach from 'lodash-es/forEach';
import has from 'lodash-es/has';
const updateSchema = (values) => {
if (!isEqual(values.frameworks, selectedFrameworks)) {
selectedFrameworks = values.frameworks;
const newSchema = cloneDeep(schema);
forEach(values.frameworks, (framework) => {
newSchema.data[framework] = {
opinion: {
id: 2,
label: `What is your opinion on ${frameworks[framework].label}?`,
component: "Textarea",
name: `data.${framework}.opinion`,
value: "",
rules: string().required("Field is required"),
},
};
});
if (values.frameworks.length) {
newSchema.data.experience = {
name: "data.experience",
label: "Past experience",
addLabel: "Add past experience",
fields: [
{
id: 3,
label: "Framework",
component: "Select",
name: "framework",
value: values.frameworks[0],
options: pick(frameworks, values.frameworks),
},
{
id: 4,
label: "Title",
component: "TextInput",
name: "title",
value: "",
rules: string().required("Field is required"),
},
{
id: 5,
label: "Company",
component: "TextInput",
name: "company",
value: "",
rules: string().required("Field is required"),
},
{
id: 6,
label: "Start date (year)",
component: "TextInput",
name: "start",
value: "",
rules: string().required("Field is required"),
},
{
id: 7,
label: "End date (year)",
component: "TextInput",
name: "end",
value: "",
rules: string().required("Field is required"),
},
],
};
}
reactiveSchema.value = newSchema;
}
};
Form component
We are going to use useForm
in this component just for accumulating values and checking if the whole form is valid. Validation is going to be initiated in each form field instead.
We are going to watch changes to form values and emit change event to update schema.
Since one of the requirements for form is support of schema with any nesting depth, we are going to create a separate field component that is going to be recursive.
<Field :field="schema" />
...
import { useForm } from 'vee-validate';
export default {
props: {
schema: {
type: Object,
required: true,
},
},
setup() {
const { values, handleSubmit, meta } = useForm();
watch(values, (newValues) => {
emit('change', newValues);
});
}
}
Field component
These are the conditions for component recursion:
- If current node contains
id
- render field component - If current node contains
addLabel
- create array field and add recursion for each added item - Else add recursion for each field child
<template>
<div v-if="hasId()" class="sm:col-span-6">
<component
:is="field.component"
v-bind="field"
v-model="value"
:error="errorMessage"
@blur="handleBlur"
></component>
</div>
<template v-else-if="isArray()">
<h4 v-show="fields.length" class="font-medium sm:col-span-6">
</h4>
<div class="sm:col-span-6 space-y-6 divide-y">
<div
v-for="(childField, index) in fields"
:key="childField.key"
class="space-y-6"
>
<Field
v-for="formField in field.fields"
:key="formField.id"
:field="{
...formField,
name: `${field.name}[${index}].${formField.name}`,
}"
/>
<button
type="button"
class="
inline-flex
items-center
px-4
py-2
border border-transparent
text-sm
font-medium
rounded-md
text-indigo-700
bg-indigo-100
hover:bg-indigo-200
focus:outline-none
focus:ring-2
focus:ring-offset-2
focus:ring-indigo-500
"
@click="remove(index)"
>
<XIcon class="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
Remove
</button>
</div>
</div>
<div class="sm:col-span-6">
<button
type="button"
class="
inline-flex
items-center
px-4
py-2
border border-transparent
shadow-sm
text-sm
font-medium
rounded-md
text-white
bg-indigo-600
hover:bg-indigo-700
focus:outline-none
focus:ring-2
focus:ring-offset-2
focus:ring-indigo-500
"
@click="push"
>
<PlusIcon class="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
Add
</button>
</div>
</template>
<template
v-for="(childField, index) in field"
v-else
:key="childField.id || index"
>
<Field :field="childField" />
</template>
</template>
<script>
import { useField, useFieldArray } from "vee-validate";
import has from "lodash-es/has";
import fromPairs from "lodash-es/fromPairs";
import map from "lodash-es/map";
import { PlusIcon, XIcon } from "@heroicons/vue/solid";
// Don't forget to import your custom components
export default {
name: "Field",
// Don't forget to declare your custom components
components: { PlusIcon, XIcon, },
props: {
field: {
type: Object,
required: true,
},
},
setup(props) {
const hasId = () => has(props.field, "id");
const isArray = () => has(props.field, "addLabel");
const result = { hasId, isArray };
if (hasId()) {
const { value, handleBlur, errorMessage } = useField(
props.field.name,
props.field.validation
);
result.value = value;
result.handleBlur = handleBlur;
result.errorMessage = errorMessage;
}
if (isArray()) {
const { remove, push, fields } = useFieldArray(props.field.name);
const getInitialValues = () =>
fromPairs(
map(props.field.fields, (field) => [field.name, field.value])
);
result.remove = (index) => {
remove(index);
};
result.push = () => {
push(getInitialValues());
};
result.fields = fields;
}
return result;
},
};
</script>
Demo
Here are the demo and source code