How to create dynamic forms with custom validation in Storyblok and Nuxt.js

Contents

Web forms are usually some of the least beloved parts of developing a website. In our experience, it has something to do with the combination of styling the forms and tackling form validation.

In this article we’ll show you how to easily set-up dynamic forms with custom validation using Storyblok, Vuelidate and TailwindCSS.

We’re not going to talk about setting up a new Storyblok/Nuxt.js project. That is excellently described in this article. Instead, we focus on the Storyblok fields setup and Vue component code. This step-by-step guide will help you set-up your own dynamic form.

We’ve recorded a video about how the end product looks once you go through the steps in this article. Feel free to check out the video on the end of this article.

Github Repo

Clone the Vue-nuxt-boilerplate repo.

Vuelidate installation

Let’s start with adding Vuelidate to your Nuxt project. This package is responsible for the editable form validation.

1. Install Vuelidate

As described in the Vuelidate setup guide, install the package using npm or yarn

npm install vuelidate --save
// OR
yarn add vuelidate

2. Create the Vuelidate plugin file

Create a plugin file vuelidate.js in the plugins directory of your project with this content:

import Vue from 'vue'
import Vuelidate from 'vuelidate'
Vue.use(Vuelidate)

3. Add the plugin to your Nuxt config file nuxt.config.js

export default {
  ...
  plugins: ['~/plugins/vuelidate'],
  ...
}

Storyblok setup

Storyblok Validator components

To achieve custom input field validation, create a counterpart of the Vuelidate validators as Storyblok components. In this example we use a few validators from the builtin Vuelidate validators. The cool thing about Vuelidate is that you can create whole new custom validators in addition to the built-in ones. The ones that we going to use are:

  • required - requires the input not be empty

  • email - requires the input be a valid email

  • numeric - requires the input be all numbers

  • minLength - requires the input to have a specified minimum length

  • maxLength - requires the input to have a specified maximum length

Each of these is represented by a separate Storyblok component.

components overview

The component names should be the exact names of the validators since we will map them dynamically to the validators from the Vuelidate package.

The email, numeric and required components have one editable field- the custom error message.

Definition of the email field

The minLength and maxLength components have an extra param field, which determines the specified length.

Definition of min-lenght requirment

Input Field component

We’re going to create a fully dynamic Form component in Storyblok where we add our input fields. Start by creating the input field component. For this tutorial, we’ve chosen to only work with the <input /> tag, but the setup can be done with all the other tags (textarea, select, etc.) as well. Let's call this component input-field.

Definition of input field

Here are the fields we want to edit in Storyblok:

name

  • the unique input identificator in HTML <input name="" />

type

  • the input type <input type="" />

  • created as a Single-Option type in Storyblok with the options: text, tel, num, email to cover the basics

label

  • the label of our input field

  • will be used for <label /> in Vue

placeholder

  • the string that gets shown when the input field is empty <input placeholder="" />

validators

  • list of field validators; only our validator components are allowed

As you can see on the screenshot below, we create additional editable fields for styling under the Styling tab. These contain TailwindCSS classes mapped to their target HTML tags in Vue.

Styling of input field

Storyblok Form component

Now we proceed to the Dynamic form component.

Definition of the dynamic form

The inputs field contains the list of inputs fields in our form. The field formEndpoint is the URL where the form sends the form data. This component has its own Styling tab with a field for custom TailwindCSS classes.

Styling of the form

The Submit button tab contains the text and the styling of the submit button.

Definition of submit button

Vue Component code

After preparing the components and fields on the Storyblok side, we create the Vue component which makes the fully dynamic form possible. We need only one component, named DynamicForm.global.vue to reflect its Storyblok counterpart. The .global suffix makes the component globally registered automatically. If you’re not familiar with this, you can read about setting it up in your Nuxt project here.

Next, we'll go through the separate parts of the component and describe their part in the functionality. Let’s start with the <script> logic part.

Component <script> logic

<script>
import * as validators from 'vuelidate/lib/validators'
...

We need to import only the validators from the Vuelidate package. This makes dynamic validation possible. Using this approach, you can decide on adding a new validator from the built-in ones without needing to change the code, just add a new Storyblok component with the validator's name.

props: {
  blok: {
    type: Object,
    required: true,
  },
}

The prop blok represents the Storyblok content. More on why it is here, and how it works, is found in the article mentioned at the beginning.

data() {
    return {
      form: this.blok.inputs.reduce(
        (prevFields, inputField) => ({
          ...prevFields,
          [inputField.name]: '',
        }),
        {}
      ),
    }
  },

Our initial form data is generated from the input components using a .reduce() function. In short, this function creates an object using the form field's unique name as a key. The values are empty string by default.

In our methods part, we have a function responsible for generating the validators for a given input-field.

generateFieldRules(fieldValidators) {
  return fieldValidators.reduce(
    (prevValidators, validator) => ({
      ...prevValidators,
      [validator.component]: validator.param
        ? validators[validator.component](validator.param)
        : validators[validator.component],
    }),
    {}
  )
},

Here, we generate an object of validators for a field using the imported validators variable containing every built-in Vuelidate validator. We use this function in the object which tells Vuelidate which input field has which validators. The ternary operator makes sure the validators' functions that are using params get called with the param as their argument.

computed: {
  fieldRules() {
    return this.blok.inputs.reduce(
      (prevFields, inputField) => ({
        ...prevFields,
        [inputField.name]: this.generateFieldRules(inputField.validators),
      }),
      {}
    )
  },
},

The fieldRules variable is used as the collection of the fields with their generated validators.

validations() {
  return {
    form: this.fieldRules,
  }
},

The validations root key is where we initiate the Vuelidate frame. You can check out their documentation here. Once the package is mounted, you can access its data and options through this.$v.form. This is also how we connected to the form data in our last method.

formSubmit(e) {
  if (this.$v.form.$invalid) {
    this.$v.form.$touch()
    e.preventDefault()
  }
},

This function is called when the form gets submitted. The $invalid property has truthy value when one of the fields doesn't pass its validation. In that case, we make the warnings visible using the $touch() method. You can read about the built-in props and methods in Vuelidate API.

Connecting all of the dots, we get the following component logic.

<script>
import * as validators from 'vuelidate/lib/validators'

export default {
  props: {
    blok: {
      type: Object,
      required: true,
    },
  },
  data() {
    return {
      form: this.blok.inputs.reduce(
        (prevFields, inputField) => ({
          ...prevFields,
          [inputField.name]: '',
        }),
        {}
      ),
    }
  },
  computed: {
    fieldRules() {
      return this.blok.inputs.reduce(
        (prevFields, inputField) => ({
          ...prevFields,
          [inputField.name]: this.generateFieldRules(inputField.validators),
        }),
        {}
      )
    },
  },
  validations() {
    return {
      form: this.fieldRules,
    }
  },
  methods: {
    generateFieldRules(fieldValidators) {
      return fieldValidators.reduce(
        (prevValidators, validator) => ({
          ...prevValidators,
          [validator.component]: validator.param
            ? validators[validator.component](validator.param)
            : validators[validator.component],
        }),
        {}
      )
    },
    formSubmit(e) {
      if (this.$v.form.$invalid) {
        this.$v.form.$touch()
        e.preventDefault()
      }
    },
  },
}
</script>

Component <template>

The Vue component template in its full glory looks like:

<template>
  <section v-editable="blok" :class="blok.class">
    <form
      :id="blok._uid"
      :action="blok.formEndpoint"
      class="flex flex-wrap"
      method="post"
      @submit="formSubmit"
    >
      <div
        v-for="inputField in blok.inputs"
        :key="inputField.name"
        v-editable="inputField"
        :class="inputField.wrapperClass"
      >
        <label :for="inputField._uid" :class="inputField.fieldLabelClass">
          {{ inputField.label }}
        </label>
        <input
          :id="inputField._uid"
          v-model.trim="$v.form[inputField.name].$model"
          :type="inputField.type"
          :name="inputField.name"
          :placeholder="inputField.placeholder"
          :class="{
            [inputField.fieldClass]: true,
            [inputField.fieldErrorClass]: $v.form[inputField.name].$error,
          }"
        />
        <div v-if="$v.form[inputField.name].$error" class="h-6">
          <div
            v-for="{ component, errorMessage } in inputField.validators"
            :key="component"
            :class="inputField.warningClass"
          >
            <div v-if="!$v.form[inputField.name][component]">
              {{ errorMessage }}
            </div>
          </div>
        </div>
      </div>
      <button type="submit" :form="blok._uid" :class="blok.submitButtonClass">
        {{ blok.submitButtonText }}
      </button>
    </form>
  </section>
</template>

As a whole, we use the TailwindCSS classes as class names for the different parts of our template. The more content we delegate to Storyblok, the more editable the styling gets.

We want to highlight two parts of the template. The first is the <input> tag.

<input 
  :id="inputField._uid"
  v-model.trim="$v.form[inputField.name].$model"
  :type="inputField.text"
  :name="inputField.name"
  :placeholder="inputField.placeholder"
  :class="{
    [inputField.fieldClass]: true,
    [inputField.fieldErrorClass]: $v.form[inputField.name].$error,
  }"
/>

We use the Storyblok-provided _uid prop as the ID of the field. For binding the input realtime to our data layer and Vuelidate, we connect them by using Vuelidates $model prop. This is available in our case through $v.form[inputField.name].$model.

The dynamic class prop enables the styling from inputField.fieldErrorClass in the case of invalid field content. We know the field has a validation error thanks to $v.form[inputField.name].$error.

The second part I want to describe is the error message container:

<div v-if="$v.form[inputField.name].$error" class="h-6">
  <div
    v-for="{ component, errorMessage } in inputField.validators"
    :key="component"
    :class="inputField.warningClass"
  >
    <div v-if="!$v.form[inputField.name][component]">
      {{ errorMessage }}
    </div>
  </div>
</div>

This code iterates through the field validators and shows a custom error message if the iterable validator has a problem. You can read about this functionality here.

Conclusion

That’s it. With that, we’re wrapping up our dynamic form. Combining the parts we’ve talked about, we’ve created the functionality presented in our showcase video.

Storyblok makes even the least popular parts of web development, forms, easy to create and manage. There is a lot more we could show you about the fusion between Storyblok and Vuelidate, but you get the idea. Make sure to contact us if you have any questions or comments about the article. You can find our contact details below.

ResourceURL
How to connect Storyblok with Nuxt.jshttps://www.storyblok.com/tp/headless-cms-nuxtjs
Video Samplehttps://www.youtube.com/watch?v=lA4VvL0ZXIg
Gary Siladi

Author

Gary Siladi

A frontend JS developer who teaches and writes. Currently working as a Senior Web Developer at twim GmbH, an enterprise consultancy for digital transformation. Interested in modern UX. Jamstack enthusiast, promoting Storyblok.