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

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

Hint text and styling

In the last article we added validation to the JVPInput.vue components using a library called Vuelidate. Upon an invalid entry to any of these components, we had a line of text appear below the input to indicate that it was invalid. Now that we have a place to put error text, we should make use of that space in case we want a hint to go there.

We have already included the hint prop in our input-props.js file, so we don't need to add anything there. Now all we need to do to pass the hint to the JVPInput component is add it anywhere we want it displayed. For example, we want to tell the user that the symbol attribute is required and the dates are optional, so we would pass these in as hints.

<div class="my-1">
  <JVPInput
    v-model="state.symbol"
    label="Symbol"
    id="symbol"
    name="symbol"
    type="text"
    placeholder="eg. MSFT"
    :vuelidate="v$.symbol"
    hint="Required, less than 6 characters"
  />
</div>
<div class="my-1">
  <JVPInput
    v-model="state.startDate"
    label="Start Date"
    id="start-date"
    name="startDate"
    type="text"
    :vuelidate="v$.startDate"
    hint="Optional"
  />
</div>
<div class="my-1">
  <JVPInput
    v-model="state.endDate"
    label="End Date"
    id="end-date"
    name="endDate"
    type="text"
    :vuelidate="v$.endDate"
    hint="Optional"
  />
</div>

Hint/error logic

The next thing we need to do is incorporate the hint vs. error logic within the component script itself. We will be using the same space to display hints and errors, but we don't want to display a hint when an input is in an error state. We also want the text displayed to be dynamic depending on the state of the input. To that end, we'll add another computed property to JVPInput.vue

const hintText = computed(() => {
  if (errorText.value.length > 0) return errorText.value;
  if (!!props.hint) return props.hint;
  return '';
})

What this little property does is it displays any error messages, if they exist. If they don't, but a hint was passed down as a prop, then display the hint, otherwise just remain empty. We can then add

const hasHint = computed(() => !!hintText.value.length);

which will be truthy whether there is a hint or there are any error messages. Now the error text markup will be replaced with

<p v-if="hasHint">{{ hintText }}</p>

after having included both hasHint and hintText in the return section of the setup function.

Styling

Now we need to add some styling. The app still looks pretty horrendous so we need to clean it up a bit. The first thing we notice, though, is that there seems to be no highlighting around the JVPSelect.vue element. If we think back a bit, we included focus:ring-indigo-500 and focus:border-indigo-500 as classes on this element but as we tab through we don't see any of those. The reason for this is because we need to install

npm i @tailwindcss/forms

and include an extra plugin for Tailwind

// tailwind.config.js
module.exports = {
  mode: 'jit',
  purge: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
  darkMode: false, // or 'media' or 'class'
  theme: {
    extend: {},
  },
  variants: {
    extend: {},
  },
  plugins: [require('@tailwindcss/forms')], // <-- *** New line ***
}

By simply adding this plugin the form looks a bit better and we can see that in the focus state the JVPSelect.vue component has the correct highlighting. Screenshot 2021-12-02 221120.png What we want to do next for the JVPInput.vue component is create a sort of dynamic class that will display certain properties when in a valid state and other properties in an error state.

// This is solely to style the input based on whether it is in an error state or not
const dynamicClass = computed(() => {
  // This is to remove padding on the right for the built-in calendar icon when using a date type
  let val = props.type === 'date' ? '' : 'pr-10';
  return isError.value
    ? `${val} border-red-300 text-red-900 placeholder-red-300 focus:outline-none focus:ring-red-500 focus:border-red-500`
    : `${val} focus:ring-indigo-500 focus:border-indigo-500 border-gray-300`;
});

We will then update the class of the hint slightly and dynamically set the id value for the hint

// The "hint" text will display any necessary hints as well as any error messages.
// Styling and values of said hint text are primarily based on whether an input is in an error state.
const hintClass = computed(() =>
  isError.value ? 'text-red-600' : 'text-gray-500'
);
const hintId = computed(() =>
  isError.value ? `${props.id}-error` : `${props.id}-input`
);

We then pass the dynamicClass variable into the return statement of the setup function and add an extra line to the input tag

<input
  :value="modelValue"
  :type="type"
  :name="id"
  :id="id"
  class="block w-full sm:text-sm rounded-md shadow-sm"
  :class="dynamicClass"
  @input="updateValue"
/>

What this will do is always set the class to be block w-full sm:text-sm rounded-md shadow-sm regardless of the error state, but then it also adds on the extra elements defined in dynamicClass, which are dependent on the error state of the input.

We then want to add a bit of extra styling to the hint text itself just to give it a secondary focus.

<p v-if="hasHint" class="mt-0 text-sm" :class="hintClass" :id="hintId">
  {{ hintText }}
</p>

after having added the hintClass and hintId elements to the return statement of the setup function, of course.

Finishing touches on JVPInput.vue

To tie off the loose ends on the JVPInput.vue component there are just a few more things we want to add

  • A couple of aria attributes
  • Tell vuelidate to validate each input on a blur event
  • Add autofocus as a prop and bind it to the input
<input
  :value="modelValue"
  :type="type"
  :name="id"
  :id="id"
  class="block w-full sm:text-sm rounded-md shadow-sm"
  :class="dynamicClass"
  :aria-invalid="isError"
  :aria-describedby="hintId"
  :autofocus="autofocus"
  @input="updateValue"
  @blur="vuelidate.$touch"
/>

You can see in the code snippet above we're accessing vuelidate and autofocus directly instead of via the props. In order to do this we simply need to update the return statement like so

return {
  hasHint,
  hintText,
  hintClass,
  hintId,
  dynamicClass,
  isError,
  autofocus: props.autofocus,
  vuelidate: props.vuelidate,
  updateValue,
};

To tie a final bow on this component, we will actually update two things in Selections.vue. You'll notice when creating the dynamicClass variable we allowed for a class of pr-10 if the type prop passed in is "date". This will automatically add in the calendar icon, so in Selections.vue we'll change the type of startDate and endDate to be "date".

Summary

Now that all of that is done, we've nearly finished with the form components. What we will do in the next, short, article will be adding in svg icons to the project so that we can easily include them in our code wherever we need. We will also style the button because the rest of the form is starting to look nice and clean and the button is still pretty ugly. For the purpose of copy-pasting, the two components we updated in this section are Selections.vue and JVPInput.vue, the updated versions of both of which are presented now.

Selections.vue

<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"
        hint="Required, less than 6 characters"
        :autofocus="true"
      />
    </div>
    <div class="my-1">
      <JVPInput
        v-model="state.startDate"
        label="Start Date"
        id="start-date"
        name="startDate"
        type="date"
        :vuelidate="v$.startDate"
        hint="Optional"
      />
    </div>
    <div class="my-1">
      <JVPInput
        v-model="state.endDate"
        label="End Date"
        id="end-date"
        name="endDate"
        type="date"
        :vuelidate="v$.endDate"
        hint="Optional"
      />
    </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>

JVPInput.vue

<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"
        :class="dynamicClass"
        :aria-invalid="isError"
        :aria-describedby="hintId"
        :autofocus="autofocus"
        @input="updateValue"
        @blur="vuelidate.$touch"
      />
    </div>
    <p v-if="hasHint" class="mt-0 text-sm" :class="hintClass" :id="hintId">
      {{ hintText }}
    </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: () => ({}),
    },
    autofocus: {
      type: Boolean,
      required: false,
      default: false
    },
    ...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(' ');
    });

    // This is solely to style the input based on whether it is in an error state or not
    const dynamicClass = computed(() => {
      // This is to remove padding on the right for the built-in calendar icon when using a date type
      let val = props.type === 'date' ? '' : 'pr-10';
      return isError.value
        ? `${val} border-red-300 text-red-900 placeholder-red-300 focus:outline-none focus:ring-red-500 focus:border-red-500`
        : `${val} focus:ring-indigo-500 focus:border-indigo-500 border-gray-300`;
    });

    // The "hint" text will display any necessary hints as well as any error messages.
    // Styling and values of said hint text are primarily based on whether an input is in an error state.
    const hintClass = computed(() =>
      isError.value ? 'text-red-600' : 'text-gray-500'
    );
    const hintId = computed(() =>
      isError.value ? `${props.id}-error` : `${props.id}-input`
    );

    const hintText = computed(() => {
      if (errorText.value.length > 0) return errorText.value;
      if (!!props.hint) return props.hint;
      return '';
    });

    const hasHint = computed(() => !!hintText.value.length);

    return {
      hasHint,
      hintText,
      hintClass,
      hintId,
      dynamicClass,
      isError,
      autofocus: props.autofocus,
      vuelidate: props.vuelidate,
      updateValue,
    };
  },
});
</script>

and the updated form looks much better Screenshot 2021-12-02 224116.png