Building a simple candlestick chart using Docker, FastAPI, and Vue 3 - Part 4

Building a simple candlestick chart using Docker, FastAPI, and Vue 3 - Part 4

Vuelidate for form validation

In the last article we created custom JVPSelect and JVPInput components that look horrendous and also that have no data validation. In this article we will add input validation using a third-party library called Vuelidate.

Initial setup

We will be instantiating the Vuelidate object in Selections.vue instead of in JVPInput.vue. We do this because the JVPInput component is, in effect, simply a wrapper. We want to be able to use validation, but the rules and validation state for the purposes of form submission need to reside where the full form data resides. The first thing we need to do is install Vuelidate

npm install @vuelidate/core @vuelidate/validators

From here we can either incorporate it globally in the project or import it into each component we want to use it. We will use Vuelidate in the latter way. We first import the library and necessary validators into our component

import useVuelidate from '@vuelidate/core';
import { required, maxLength } from '@vuelidate/validators';

Then we instantiate the object and set the validation rules for the symbol attribute of the form

const rules = {
  symbol: { required, maxLength: maxLength(5) },
};
const v$ = useVuelidate(rules, state);

where the state element is the reactive element that we've already set in the component. The above rules set the symbol attribute to be required and to be no longer than 5 characters, though you can set it to whatever you want.

Custom validators

We also want to check that if we've included data for both the startDate and endDate attributes that the former is earlier than the latter.

We first need to extend the rules that were initially created before

const rules = {
  symbol: { required, maxLength: maxLength(5) },
  startDate: {
    validateDateFormat,
    mustBeEarlierDate,
  },
  endDate: {
    validateDateFormat,
  },
};
const v$ = useVuelidate(rules, state });

The first thing we want to do is first check to see if the value passed in is a valid date.

const checkDateFormat = (param) => {
  if (!param) return true;
  return new Date(param).toString() !== 'Invalid Date';
};

const validateDateFormat = helpers.withMessage(
  'Please enter a valid date.',
  checkDateFormat
);

The checkDateFormat function follows methodology in the Custom error messages documentation on the Vuelidate docs.

Then we want to check that the value passed in is earlier than the reactive state's endDate value.

const mustBeEarlierDate = helpers.withMessage(
  'Start date must be earlier than end date.',
  (value) => {
    const endDate = computed(() => state.endDate);
    if (!value || !endDate.value) return true;
    if (!checkDateFormat(endDate.value)) return true;
    return new Date(value) < new Date(endDate.value)
  }
);

The mustBeEarlierDate function follows methodology in the Passing extra properties to validators documentation on the Vuelidate docs. We need to be careful, though, as we don't care about this validation if either of the dates is not included, but only if both are set.

We can now check to see that the functionality works properly by inputting valid and invalid values to the form and clicking the Get Chart button. We should see the triggered handleSubmit text any time we click the button except when both the start and end date being included and the start date is not strictly less than the end date.

The updated Selections.vue component should now look like this

<template>
  <form class="ml-5" @submit.prevent="handleSubmit">
    <div class="my-1">
      <JVPInput
        v-model="state.symbol"
        label="Symbol"
        id="symbol"
        name="symbol"
        type="text"
        placeholder="eg. MSFT"
        :vuelidate="v$.symbol"
      />
    </div>
    <div class="my-1">
      <JVPInput
        v-model="state.startDate"
        label="Start Date"
        id="start-date"
        name="startDate"
        type="text"
        :vuelidate="v$.startDate"
      />
    </div>
    <div class="my-1">
      <JVPInput
        v-model="state.endDate"
        label="End Date"
        id="end-date"
        name="endDate"
        type="text"
        :vuelidate="v$.endDate"
      />
    </div>
    <div class="my-1">
      <JVPSelect
        v-model="state.interval"
        label="Interval"
        id="interval"
        :options="intervals"
      />
    </div>
    <button type="submit" class="border-4 border-indigo-500">Get Chart</button>
  </form>
</template>

<script>
import { defineComponent, reactive, computed } from 'vue';
import useVuelidate from '@vuelidate/core';
import { required, maxLength, helpers } from '@vuelidate/validators';
import JVPInput from './JVPInput.vue';
import JVPSelect from './JVPSelect.vue';

export default defineComponent({
  components: { JVPInput, JVPSelect },
  setup() {
    const intervals = ['Daily', 'Weekly', 'Monthly'];

    const state = reactive({
      symbol: '',
      interval: 'Daily',
      startDate: '',
      endDate: '',
    });

    const mustBeEarlierDate = helpers.withMessage(
      'Start date must be earlier than end date.',
      (value) => {
        const endDate = computed(() => state.endDate);
        if (!value || !endDate.value) return true;
        if (!checkDateFormat(endDate.value)) return true;
        return new Date(value) < new Date(endDate.value);
      }
    );

    const checkDateFormat = (param) => {
      if (!param) return true;
      return new Date(param).toString() !== 'Invalid Date';
    };

    const validateDateFormat = helpers.withMessage(
      'Please enter a valid date.',
      checkDateFormat
    );

    const rules = {
      symbol: { required, maxLength: maxLength(5) },
      startDate: {
        validateDateFormat,
        mustBeEarlierDate,
      },
      endDate: {
        validateDateFormat,
      },
    };
    const v$ = useVuelidate(rules, state);

    const disabled = computed(() => {
      return v$.value.$invalid;
    });

    const handleSubmit = () => {
      v$.value.$validate();
      if (!v$.value.$invalid) {
        console.log('triggered handleSubmit');
      }
    };

    return {
      intervals,
      state,
      disabled,
      v$,
      handleSubmit,
    };
  },
});
</script>

Display error messages

This is all fine and good, but we need to somehow let the user know when the form is in an error state. In order to do this we need to add v$ to the return of the setup function in the component so that we can access it in the template and pass it down to the JVPInput.vue components. Next we need to update the props in the JVPInput.vue component to include this new object, which we'll simply call vuelidate. The props for this component should now look like

props: {
  type: {
    type: String,
    required: false,
    default: 'text',
  },
  placeholder: {
    type: String,
    required: false,
  },
  vuelidate: {
    type: Object,
    required: false,
    default: () => ({})
  },
  ...inputProps,
},

and we'll add in an extra attribute to each JVPInput instance in Selections.vue, calling the specific element of the v$ object. It will look something like

<JVPInput
  v-model="state.startDate"
  label="Start Date"
  id="start-date"
  name="startDate"
  type="text"
  :vuelidate="v$.startDate"
/>

Once this is done we can then set some computed properties in the JVPInput.vue component to check if the input is in an error state and, if it is, create a dynamic errorText element.

// Vuelidate is used as a validation library.
// We use built-in functionality to determine if any rules are violated
// as well as display of the associated error text
const isError = computed(() => props.vuelidate.$error);
const errorText = computed(() => {
  const messages =
    props.vuelidate.$errors?.map((err) => err.$message) ?? [];
  return messages.join(' ');
});

We then want to add the isError and errorText elements to the return statement in the setup function in JVPInput.vue and add in some markup to display the text if and only if there is an error. We can add in a simple <p> tag to conditionally display the error messages, and the updated JVPInput.vue component should look like

<template>
  <div>
    <label :for="id" class="block text-sm font-medium text-gray-700">
      {{ label }}
    </label>
    <div class="mt-1 relative rounded-md shadow-sm">
      <input
        :value="modelValue"
        :type="type"
        :name="id"
        :id="id"
        class="block w-full sm:text-sm rounded-md shadow-sm"
        @input="updateValue"
      />
    </div>
    <p v-if="isError">{{ errorText }}</p>
  </div>
</template>

<script>
import { defineComponent, computed } from 'vue';
import inputProps from '../utils/input-props';

export default defineComponent({
  props: {
    type: {
      type: String,
      required: false,
      default: 'text',
    },
    placeholder: {
      type: String,
      required: false,
    },
    vuelidate: {
      type: Object,
      required: false,
      default: () => ({}),
    },
    ...inputProps,
  },
  emits: ['update:modelValue'],
  setup(props, { emit }) {
    // This is simply cleaner than putting emit code in the HTML
    const updateValue = (e) => emit('update:modelValue', e.target.value);

    // Vuelidate is used as a validation library.
    // We use built-in functionality to determine if any rules are violated
    // as well as display of the associated error text
    const isError = computed(() => props.vuelidate.$error);
    const errorText = computed(() => {
      const messages =
        props.vuelidate.$errors?.map((err) => err.$message) ?? [];
      return messages.join(' ');
    });

    return { updateValue, isError, errorText };
  },
});
</script>

Now we can see that if we don't include a symbol, set the date fields to be random text, and click the Get Chart button then we should see something that looks like Screenshot 2021-12-02 172542.png In the next article we'll add in hint text, then we'll add in some conditional styling based on whether the form is in an error state or not.