Series: Building reusable custom components with Vue.js

Series: Building reusable custom components with Vue.js

Part 4: Date range picker

September 29, 2017

Intro

In the first three parts of this series we looked at an approach for building an autocomplete drop-down by starting with a basic component and adding functionality to it. We used v-model support in Vue.js to provide a consistent interface between our component and its parent.

In this post we will use the same principles to implement a date range picker. Our component will include a mix of custom code and third party libraries but we will ensure it is well-encapsulated and reusable.

Getting started

Back in Part 2 I outlined 4 principles for setting up a component to use v-model:

  1. Determine how your component’s current value will be represented (e.g. string, number, custom object)
  2. emit an “input” event when the selected value changes (how and when you do this may change depending on the details of your component)
  3. Provide a value prop which accepts valid values for your component
  4. Ensure that the controls in your component reflect this value when mounted and when the value of the prop changes.

These principles can help to keep your components concerns cleanly separated and makes it easier to reuse a given component in other parts of your site.

This becomes even more crucial when your components aren’t just small wrappers around HTML elements but instead are larger compound components with a mix of custom functionality and third party libraries.

We will be building a Date Range picker. This control allows a user to quickly specify a start and end date either by selecting from a pre-set list of common ranges (Yesterday, Last Week, Last Month, This Month, etc.) or by entering a custom start and end date.

This is how it looks in action:

Let’s get started by creating an empty DateRangePicker.vue file in our project:

<template>
  <div>
    TBD
  </div>
</template>

<script>

export default {

}

</script>

and adding it to our Filter.vue template:

<div class="filter">
  <date-range-picker></date-range-picker>
</div>

<div class="result">
  Selected range: <strong>TBD</strong>
</div>
<script>

import DateRangePicker from '@/components/DateRangePicker'

export default {
  components: {
    'date-range-picker': DateRangePicker
  },

  data () {
    return {
      selectedDateRange: null
    }
  }
}

</script>

Note: if you’ve been following along, you already have a bunch of code in Filters.vue. I’ve omitted that here for clarity but feel free to leave it in. It will work fine with our additions.

We will hook this up so it displays a value as we go along.

1. Determine how your component’s current value will be represented

For our use here let’s define a date range as a pair of Dates. We’ll encapsulate these in an object with the format:

{
  from: [Date],
  to: [Date]
}

We will emit an object in this format when our component’s value changes and a parent can set our component’s value with an object in this format.

2. emit an “input” event when the selected value changes

Pre-set range types

The simplest way for a user to select a value with our component is to choose a range type from a pre-set list. Our component will calculate an appropriate value to match the range type and emit that.

The set of range types we will support is:

  • Yesterday
  • Today
  • This Week
  • Last Week
  • This Month
  • Last Month
  • Last 3 Months
  • Last 6 Months

Let’s add those as a data property:

data () {
  return {
    rangeTypeOptions: {
      yesterday: 'Yesterday',
      today: 'Today',
      thisweek: 'This Week',
      lastweek: 'Last Week',
      thismonth: 'This Month',
      lastmonth: 'Last Month',
      last3months: 'Last 3 Months',
      last6months: 'Last 6 Months'
    }
  }
}

then render a select with those options:

<select>
  <option v-for="(option, value) in rangeTypeOptions" :value="value">{{ option }}</option>
</select>

We’ll also add a handler for when the value of this select changes:

<select @change="(event) => { dateRangeChanged(event.target.value) }">
methods: {
  calculateRangeForType (rangeType) {
    // TODO: return a date range object based on the supplied range type
  },

  dateRangeChanged (rangeType) {
    var dateRange = this.calculateRangeForType(rangeType)

    this.$emit('input', dateRange)
 }
}

Calculating ranges with moment.js

The beauty of Vue.js is that it makes it easy to compose well-encapsulated components that use a mix of built-in, custom, and third party functionality.

For our range calculations we will use moment.js.

Begin by installing the moment.js package with npm:

npm install --save moment

Then we can import it into our component:

import moment from 'moment'

export default {

  methods: {
    calculateRangeForType (rangeType) {
      // TODO: return a date range object based on the supplied range type
    },

    dateRangeChanged (rangeType) {
      var dateRange = this.calculateRangeForType(rangeType)

      this.$emit('input', dateRange)
    }
  }

}

With moment.js it’s ridiculously easy to generate correct from and to values for our various range types:

calculateRangeForType (rangeType) {
  switch (value.rangeType) {
    case 'yesterday':
      return {
        from: moment().subtract(1, 'day').startOf('day').toDate(),
        to: moment().subtract(1, 'day').startOf('day').toDate()
      }

    case 'today':
      return {
        from: moment().startOf('day').toDate(),
        to: moment().endOf('day').toDate()
      }

    case 'thisweek':
      return {
        from: moment().startOf('week').toDate(),
        to: moment().endOf('week').toDate()
      }

    case 'lastweek':
      return {
        from: moment().subtract(1, 'week').startOf('week').toDate(),
        to: moment().subtract(1, 'week').endOf('week').toDate()
      }

    case 'thismonth':
      return {
        from: moment().startOf('month').toDate(),
        to: moment().endOf('month').toDate()
      }

    case 'lastmonth':
      return {
        from: moment().subtract(1, 'month').startOf('month').toDate(),
        to: moment().subtract(1, 'month').endOf('month').toDate()
      }

    case 'last3months':
      return {
        from: moment().subtract(3, 'month').startOf('month').toDate(),
        to: moment().endOf('month').toDate()
      }

    case 'last6months':
      return {
        from: moment().subtract(6, 'month').startOf('month').toDate(),
        to: moment().endOf('month').toDate()
      }

    default:
      return null
  }
},

Displaying the selected range

At this point we can add a v-model directive to our component and see our picker in action:

<div class="filter">
  <date-range-picker v-model="selectedDateRange"></date-range-picker>
</div>

<div class="result">
  Selected range: <strong v-if="selectedDateRange">{{ selectedDateRange.from }} - {{ selectedDateRange.to }}</strong>
</div>

Sweet! Also, UGLY! Let’s detour for a second and add a quick computed property to massage the picked value into something more readable.

Note: We’re using moment.js for this so don’t forget to import it at the top of Filters.vue.

computed: {
  selectedDateRangeFormatted () {
    if (this.selectedDateRange) {
      return moment(this.selectedDateRange.from).format('L LT') + ' - ' + moment(this.selectedDateRange.to).format('L LT')
    } else {
      return 'None'
    }
  }
}

Much better.

Picking custom ranges

At this point we have a useful, but basic, component. We are going to enhance its functionality by letting the user specify a custom date range using a nice-looking date picker. As we do this, note how we don’t have to make any changes to our implementation in Filters.vue.

This is the key of this whole series. We want to create components that are well-encapsulated so that we can enhance them over time without having to rewrite the parent components.

We will use the vuejs-datepicker library for our start and end dates and v-model their values to a pair of data properties:

npm install --save vuejs-datepicker 
import DatePicker from 'vuejs-datepicker'
components: {
'date-picker': DatePicker
},

data () {
  return {
    fromPickedDate: null,
    toPickedDate: null
  }
},
<template>
  <div>
    <select v-model="selectedRangeType" @change="(event) => { dateRangeChanged(event.target.value) }">
      <option v-for="(option, value) in rangeTypeOptions" :value="value">{{ option }}</option>
    </select>

    <label>From: <date-picker v-model="fromPickedDate" format="MM/dd/yyyy"></date-picker></label>
    <label>To: <date-picker v-model="toPickedDate" format="MM/dd/yyyy"></date-picker></label>
  </div>
</template>

These look good but we only want to show them when the user specifically selects “Custom” as the range type. We need to add an option for “Custom” to our dropdown and v-model the selectedRangeType to a data property so we can use it to control the display of the “From” and “To” options in our template:

data () {
return {
  rangeTypeOptions: {
    yesterday: 'Yesterday',
    today: 'Today',
    thisweek: 'This Week',
    lastweek: 'Last Week',
    thismonth: 'This Month',
    lastmonth: 'Last Month',
    last3months: 'Last 3 Months',
    last6months: 'Last 6 Months',
    custom: 'Custom Range...'
  },
  selectedRangeType: null,
  fromPickedDate: null,
  toPickedDate: null
}
},
<template>
  <div>
    <select v-model="selectedRangeType" @change="(event) => { dateRangeChanged(event.target.value) }">
      <option v-for="(option, value) in rangeTypeOptions" :value="value">{{ option }}</option>   
    </select>

    <div v-if="selectedRangeType == 'custom'">
      <label>From: <date-picker 
                     v-model="fromPickedDate" 
                     format="MM/dd/yyyy" 
                     ></date-picker>
      </label>
      <label>To: <date-picker 
                   v-model="toPickedDate" 
                   format="MM/dd/yyyy" 
                   ></date-picker>
      </label>
    </div>
  </div>
</template>

Emitting custom ranges

Note that the user can pick From and To dates for their custom range we need to properly handling the emitted value. Right now, we are emitting an input event when the user selects “Custom Range…”. Since calculateRangeForType doesn’t have a case for custom our component emits null.

Our desired behaviors are:

  • If the user picks a pre-set range type, emit a matching range value
  • If the user picks “Custom Range..” and either From or To is still empty, do nothing
  • If the user picks “Custom Range..”, and From or To are both provided, emit a matching range value
  • If the user changes a picked date value, and From or To are both provided, emit a matching range value

If the user picks a pre-set range type, emit a matching range value

The first behavior is already working. Rock.

If the user picks “Custom Range…” and either From or To is still empty, do nothing

Let’s add a check to dateRangeChanged() to handle this:

dateRangeChanged (rangeType) {
  if (this.selectedRangeType === 'custom') {
    if (this.fromPickedDate == null || this.toPickedDate == null) {
      return
    }
  } else {
    let dateRange = this.calculateRangeForType(rangeType)
    this.$emit('input', dateRange)
  }
}

If the user picks “Custom Range..”, and From or To are both provided, emit a matching range value

This can be handled by adding an else block to the conditional we just added:

dateRangeChanged (rangeType) {
  if (this.selectedRangeType === 'custom') {
    if (this.fromPickedDate == null || this.toPickedDate == null) {
      return
    } else {
      let dateRange = {
        from: moment(this.fromPickedDate).startOf('day').toDate(),
        to: moment(this.toPickedDate).endOf('day').toDate()
      }

      this.$emit('input', dateRange)
    }
  } else {
    let dateRange = this.calculateRangeForType(rangeType)
    this.$emit('input', dateRange)
  }
}

If the user changes a picked date value, and From or To are both provided, emit a matching range value

At this point we will only see our custom range if the user selects a pre-set range and then switches it back to “Custom Range…”. We can fix this and handle the last behavior by binding to the date picker’s input events:

<div v-if="selectedRangeType == 'custom'">
  <label>From: <date-picker 
                 v-model="fromPickedDate" 
                 format="MM/dd/yyyy" 
                 @input="dateRangeChanged('custom')"
                 ></date-picker>
  </label>
  <label>To: <date-picker 
               v-model="toPickedDate" 
               format="MM/dd/yyyy" 
               @input="dateRangeChanged('custom')"
               ></date-picker>
  </label>
</div>

Whew! We now have pickable custom date ranges!

3. Provide a value prop which accepts valid values for your component

The other half of implementing v-model is adding a value prop which the parent can use to set the value of your component.

For many simple cases you can simple add the prop by name like so:

props: [ 'value' ],

However, recall that we are emitting values in this format:

{
  from: [Date],
  to: [Date]
}

We should add a validation function for our prop to ensure that our code is providing correctly formatted values. Let’s structure our prop definition like so:

props: {
  value: {
    type: Object,
    validator: function (value) {
        // Validation logic will go here
    }
  }
}

For our component, null is valid and means “No date range selected”. Otherwise, we expect to have an Object with two properties, from and to and the values of those properties should be Dates:

props: {
  value: {
    type: Object,
    validator: function (value) {
      if (value === null) {
        return true
      }
     
      return 'from' in value &&)
             value.hasOwnProperty('from') &&
             'to' in value &&
             value.hasOwnProperty('to') &&
             typeof value.from === 'Date' &&
             typeof value.to === 'Date'
    }
  }
}

You can quickly test this validation function by editing the selectedDatePrange property in Filters.vue.

Try setting it to:

selectedDateRange: {
  from: 'Invalid',
  to: 'Dates'
}

and check your console. You should see an error:

Invalid prop: custom validator check failed for prop "value".

Now, change selectedDateRange to:

selectedDateRange: {
  from: new Date(),
  to: new Date()
}

You should see no errors.

4. Ensure that the controls in your component reflect this value when mounted and when the value of the prop changes.

We can now ensure that our component responds appropriately when the parent changes the value prop. Typically this means updating the state of the component’s UI to reflect the new value.

We will do this in a new updateComponentWithValue method:

updateComponentWithValue (newValue) {

}

We have two cases to handle. The first case is if the new value is null. In that case we want to ensure that our select and both of the date pickers are set to null:

updateComponentWithValue (newValue) {
  if (newValue === null) {
    this.selectedRangeType = null
    this.fromPickedDate = null
    this.toPickedDate = null
  }
}

The second case is if a specific range is provided. Since the value will only have a from and to date provided, it isn’t simple to determine what pre-set range type is represented. For now, we will treat any provided range as a custom range:

updateComponentWithValue (newValue) {
  if (newValue === null) {
    this.selectedRangeType = null
    this.fromPickedDate = null
    this.toPickedDate = null
  } else {
    this.selectedRangeType = 'custom'
    this.fromPickedDate = newValue.from
    this.toPickedDate = newValue.to
  }
}

We then need to make sure we call this method when the component is mounted:

mounted () {
  this.updateComponentWithValue(this.value)
},

and any time the value prop changes:

watch: {
  value: function (newValue) {
    this.updateComponentWithValue(newValue)
  }
}

Summary

We now have full-featured date range picker which includes multiple ways to specify the range and includes a nice looking third-party calender control.

Note how all of the logic for selecting a date is encapsulated within our component. The parent component, Filters.vue is not involved at all with the specifics of how the user chooses each date. It only cares about what the chosen range is.

Also, note how the Date Range Picker does not specify how the resulting dates range is formatted. Filters.vue contains all of the logic for formatting the value in its selectedDateRangeFormatted computed value.

This clean separation of concerns using v-model as the interface layer is very powerful. It helps to avoid creating components that are either too specific to a particular use case that they can’t be reused or too intertwined with their parent component that the break when included somewhere else.

What we have is generic and reusable.

Additionally, we can continue to improve the behavior of our component over time without necessarily needing to constantly revisit the parent component’s behavior.

Bonus: handling pre-set range types

In step 4 above I implemented updateComponentWithValue in a naive way which doesn’t take into account pre-set range types at all. This was fine for demonstration purposes but isn’t very satisfying. It’s especially noticeable if we add a second Date Range Picker to Filters.vue:

<div class="filter">
  <date-range-picker v-model="selectedDateRange"></date-range-picker>
</div>

<div class="filter">
  <date-range-picker v-model="selectedDateRange"></date-range-picker>
</div>

<div class="result">
  Selected range: <strong v-if="selectedDateRange">{{ selectedDateRangeFormatted }}</strong>
</div>

So, let me show you how we can add in support for parsing a pre-set range type from just a from and to date. This will help to drive home how we can constantly improve our components without messing with their parents.

Our approach will be to look at the incoming date range and compare its values to what the current value of each pre-set range is, in turn. This is easy to do with our calculateRangeForType method:

updateComponentWithValue (newValue) {
  if (newValue === null) {
    this.selectedRangeType = null
    this.fromPickedDate = null
    this.toPickedDate = null
  } else {
    var rangeTypes = Object.keys(this.rangeTypeOptions)
    for (var i = 0; i < rangeTypes.length; i++) {
      let preSetRange = this.calculateRangeForType(rangeTypes[i])

      if (preSetRange === null) {
        continue
      }

      if (newValue.from.getTime() === preSetRange.from.getTime() &&
          newValue.to.getTime() === preSetRange.to.getTime()) {
        this.selectedRangeType = rangeTypes[i]
        this.fromPickedDate = null
        this.toPickedDate = null
        return
      }
    }

    this.selectedRangeType = 'custom'
    this.fromPickedDate = newValue.from
    this.toPickedDate = newValue.to
  }
}
},

Let’s break that down.

First, we loop over all of our pre-set range types:

var rangeTypes = Object.keys(this.rangeTypeOptions)
for (var i = 0; i < rangeTypes.length; i++) {

}

For each one, get the pre-set date range that goes with it:

let preSetRange = this.calculateRangeForType(rangeTypes[i])

One of the range type options is ‘custom’ which will doesn’t have a pre-set date range, it will just be null. If we see that, just skip it:

if (preSetRange === null) {
  continue
}

Next, we take the from and to components of the new date range value and compare them to the values form the pre-set range:

if (newValue.from.getTime() === preSetRange.from.getTime() &&
  newValue.to.getTime() === preSetRange.to.getTime()) {
}

If they match, then we set the selected range type in our drop-down to the current range type in our loop and clear out the date picker values.

If we loop through all of the pre-set range types and don’t find a match, then this is a custom date and we can set it like so:

this.selectedRangeType = 'custom'
this.fromPickedDate = newValue.from
this.toPickedDate = newValue.to

Now our component behaves quite nicely with lots of them on the page.

When you are building your own reusable components it is critical to test them out with multiple instances bound to the same value. You can catch a lot of inconsistencies in your implementation this way.

Posts in this series


Add new comment