Build an advanced search and filter with Vuex (in Nuxt)

What are we building?

A filter! We want to be able to search through our leads, filter them by status, and change the order. But, we also want all of these filters to work together and chain.

2019-12-06-13.48.49.gif

Get Started

So to keep this as short as possible I’ll refrain from going through the process of setting up a new Nuxt project. This should also work fine with plain old Vuex.

Here’s some assumptions:

  • You already have a project setup
  • You have some type of data you want to filter
  • You know the basics of Vuex and store management

Project structure

For my example I’m using a project I’ve been working on (did I mention it’s open source? 👉 https://github.com/messerli90/jobhuntbuddy).

We have a bunch of job openings (we’re calling leads) we’d like to track, but the list is getting long and we want to be able to:

  1. Search by company name and job title
  2. Show only leads in a certain status
  3. Order them by: created date, company name, job title, or status
  4. Not make an API call every time the filter changes, all changes to the list should remain local

Let’s get started

Set up the Vuex store

We have a store setup that has our list of leads and current lead in state. We want to add a new list of filteredLeads and an initial filter object to our state.

// ~/store/leads.js
export const state = () => ({
  leads: [],
  filteredLeads: [],
  lead: {},
  filter: {
    search: '',
    status: 'all',
    order: 'createdAt'
  }
})

We want to keep the initial list of leads we get back from the API to remain untouched, so when we clear our filters we can just grab all of our leads again.

Actions

Let’s define the actions our Vue component will be able to call when we make changes to our filter.

I’ve prefixed all these methods with ‘filter’ so know it all belongs together.

For filterStatusfilterSearch, and filterOrder we first commit a mutation to store them in the filter object we just created. This way, when we can maintain a single source of truth when calling the filterLeads method.

Since we want to make all of our filters be maintained no matter which value we change the final filterLeads action will first narrow down our list to what we want and then order our new list.

// ~/store/leads.js
export const actions = {
// ...
  async filterOrder ({ commit }, order) {
    await commit('setOrder', order)
    await commit('orderLeads')
  },
  async filterStatus ({ commit, dispatch }, status) {
    await commit('setFilterStatus', status)
    dispatch('filterLeads')
  },
  async filterSearch ({ commit, dispatch }, search) {
    await commit('setFilterSearch', search)
    dispatch('filterLeads')
  },
  async filterLeads ({ commit }) {
    await commit('filterLeads')
    await commit('orderLeads')
  },
  // ...
}

Mutations

Now let’s look at the mutations we just commited.

setFilteredLeads gets called after applying a new filter so our Vue component shows only the leads we want to see, without losing our initial list.

setFilterStatussetFilterSearch, and setOrder are only responsible for changing the respective value on the filter object.

filterLeads first makes a local copy of all leads. We reset our filteredLeads list to include all leads. Finally, we call our filter method and store this new list on the state.

Similarly, orderLeads grabs this new list of filteredLeads, passes it on to our ordering method, and saves our new list.

// ~/store/leads.js
import * as Filters from '~/helpers/filters'

export const mutations = {
  // ...
  setFilteredLeads (state, leads) { state.filteredLeads = leads },

  setFilterStatus (state, status) { state.filter.status = status },
  setFilterSearch (state, search) { state.filter.search = search },
  setOrder (state, order) { state.filter.order = order },

  filterLeads (state) {
    const leads = [...state.leads]
    state.filteredLeads = leads
    state.filteredLeads = Filters.filterLeads(state.filter, leads)
  },
  orderLeads (state) {
    const leads = [...state.filteredLeads]
    state.filteredLeads = Filters.orderLeads(state.filter.order, leads)
  }
  // ...
}

And that’s all we have to change in our Vuex store. Let’s move on to our filtering helper methods

Filter Helpers

This is where the magic happens. We saw in the last step our mutations called Filter.filterLeads(state.filter, leads) and Filter.orderLeads(state.filter.order, leads) so let’s create these and do some sorting!

Disclaimer: This works, but I am in no way a javascript rockstar and if you have any tips on how to optimize this I am excited to hear from you!

Recap
Remember what our filter object looks like:

filter: {
  search: '',
  status: 'all',
  order: 'createdAt'
}

filterLeads(filter, leads)

// ~/helpers/filters.js
export function filterLeads (filter, leads) {
  let filteredList = [...leads]

  // Filter status
  if (filter.status !== 'all') {
    const filtered = filteredList.filter(lead => lead.status === filter.status)
    filteredList = filtered
  }

  // Search
  if (filter.search !== '') {
    const searchList = []
    const searchTerm = filter.search.toLowerCase()
    for (let i = 0; i < filteredList.length; i++) {
      if (
        (filteredList[i].companyName !== null && filteredList[i].companyName.toLowerCase().includes(searchTerm)) ||
        (filteredList[i].jobTitle !== null && filteredList[i].jobTitle.toLowerCase().includes(searchTerm))
      ) {
        searchList.push(filteredList[i])
      }
    }
    filteredList = searchList
  }

  return filteredList
}

Read more about includes() on MDN: String.prototype.includes()

Since the search loops through all of our leads to make a text match, we’ll do that last to save it from running unnecessary iterations. Let’s first first filter through our list to find any leads that match our status filter.

Now that we have this shorter list we can pass that on to the search logic. If the search field is empty we should skip this whole step. (Remember that we reset our filteredLeads list back to our initial leads list before calling this). Otherwise, make sure to use .toLowerCase() on both the search term and the attribute you want to filter because javascript treats ‘A’ & ‘a’ differently and the won’t match otherwise. Any matches get pushed to our new searchList and then replace our filteredList.

orderLeads(order, leads)

// ~/helpers/filters.js
import moment from 'moment'
export function orderLeads (order, leads) {
  const orderedList = [...leads]

  if (order === 'createdAt') {
    orderedList.sort(function (a, b) {
      const unixA = moment(a.createdAt).unix()
      const unixB = moment(b.createdAt).unix()
      return unixA < unixB ? -1 : 1
    })
  } else {
    orderedList.sort(function (a, b) {
      const nameA = a[order] ? a[order].toLowerCase() : 'zzz'
      const nameB = b[order] ? b[order].toLowerCase() : 'zzz'
      return nameA < nameB ? -1 : 1
    })
  }

  return orderedList
}

Read more about sort() on MDN: Array.prototype.sort()

This is our order method. Since currently we’re only ordering by company namejob titlestatus, and created at we only need two types of ordering functions: Date and String.

So, if the order is ‘createdAt’, and we know that lead.createdAt is a timestamp we transform it to a unix timestamp so it’s easier to compare. I use Moment.js here which may be overkill.

Otherwise, our other ordering methods are all strings so we can treat them the same (assuming our order and object key are equal!). I’ve also decided that if a lead doesn’t have a certain value (i.e. jobTitle) we’ll default this to ‘zzz’ so it gets pushed to the end of the list.

Then we return our orderList (which has already been filtered)

Presentation Layer

Now that all the ground work has been done in our Vuex store, let’s move on to the Vue component that puts this all together.

Lead Filter

Our filter component

// ~/components/leads/leadFilter.vue
<template>
  <div>
    <div class="w-full mb-2">
      <input
        :value="search"
        type="search"
        class="h-12 p-4 mb-1 w-full bg-white border-2 border-gray-300 rounded-full"
        placeholder="Search company name or job title"
        aria-label="Search by company name or job title"
        @input="handleSearch"
      >
    </div>
    <div class="mb-4 w-full">
      <div class="flex flex-wrap items-center justify-center md:justify-between w-full text-gray-800">
        <button
          class="bg-gray-400 rounded-full px-3 py-2 font-medium text-center text-sm m-1 hover:bg-gray-500"
          :class="{ 'bg-indigo-700 text-white hover:bg-indigo-800' : status === 'all' }"
          @click="handleStatusFilter('all')"
        >
          All Leads
        </button>
        <button
          class="bg-gray-400 rounded-full px-3 py-2 font-medium text-center text-sm m-1 hover:bg-gray-500"
          :class="{ 'bg-yellow-500 text-white hover:bg-yellow-600' : status === 'prospect' }"
          @click="handleStatusFilter('prospect')"
        >
          Prospects
        </button>
        <button
          class="bg-gray-400 rounded-full px-3 py-2 font-medium text-center text-sm m-1 hover:bg-gray-500"
          :class="{ 'bg-green-500 text-white hover:bg-green-600' : status === 'application-sent' }"
          @click="handleStatusFilter('application-sent')"
        >
          Application Sent
        </button>
        <button
          class="bg-gray-400 rounded-full px-3 py-2 font-medium text-center text-sm m-1 hover:bg-gray-500"
          :class="{ 'bg-blue-500 text-white hover:bg-blue-600' : status === 'interview-set' }"
          @click="handleStatusFilter('interview-set')"
        >
          Interview Set
        </button>
        <button
          class="bg-gray-400 rounded-full px-3 py-2 font-medium text-center text-sm m-1 hover:bg-gray-500"
          :class="{ 'bg-red-500 text-white hover:bg-red-600' : status === 'rejected' }"
          @click="handleStatusFilter('rejected')"
        >
          Rejected
        </button>
      </div>
    </div>
    <div class="flex justify-start">
      <div class="relative mb-3 pr-8">
        <p
          v-click-outside="closeOrderDropDown"
          class="text-gray-700 cursor-pointer flex items-center"
          @click="orderOpen = !orderOpen"
        >
          <fa :icon="['fas', 'sort-amount-down']" class="h-4 mx-1" />
          <span class="mr-1">Order By</span>
          <span v-show="orderChanged" class="font-semibold">{{ orderText }}</span>
        </p>
        <ul v-show="orderOpen" class="bg-white absolute z-20 px-3 py-2 mt-1 rounded shadow-lg text-gray-700 min-w-full">
          <li
            class="cursor-pointer pb-1 hover:text-indigo-600"
            :class="{ 'text-indigo-600 font-semibold' : order === 'createdAt' }"
            @click="handleFilterOrder('createdAt')"
          >
            Created Date
          </li>
          <li
            class="cursor-pointer pb-1 hover:text-indigo-600"
            :class="{ 'text-indigo-600 font-semibold' : order === 'companyName' }"
            @click="handleFilterOrder('companyName')"
          >
            Company Name
          </li>
          <li
            class="cursor-pointer hover:text-indigo-600"
            :class="{ 'text-indigo-600 font-semibold' : order === 'jobTitle' }"
            @click="handleFilterOrder('jobTitle')"
          >
            Job Title
          </li>
          <li
            class="cursor-pointer hover:text-indigo-600"
            :class="{ 'text-indigo-600 font-semibold' : order === 'status' }"
            @click="handleFilterOrder('status')"
          >
            Status
          </li>
        </ul>
      </div>
    </div>
  </div>
</template>

<script>
import { debounce } from '~/helpers/index'
export default {
  data () {
    return {
      orderOpen: false,
      orderChanged: false
    }
  },
  computed: {
    search () {
      return this.$store.state.leads.filter.search
    },
    status () {
      return this.$store.state.leads.filter.status
    },
    order () {
      return this.$store.state.leads.filter.order
    },
    orderText () {
      switch (this.order) {
        case 'companyName':
          return 'Company Name'
        case 'jobTitle':
          return 'Job Title'
        case 'status':
          return 'Status'
        default:
          return 'Created Date'
      }
    }
  },
  methods: {
    handleStatusFilter (status) {
      this.$store.dispatch('leads/filterStatus', status)
    },
    handleSearch: debounce(function (e) {
      this.$store.dispatch('leads/filterSearch', e.target.value)
    }, 500),
    handleFilterOrder (orderBy) {
      this.orderOpen = false
      this.orderChanged = true
      this.$store.dispatch('leads/filterOrder', orderBy)
    },
    closeOrderDropDown (e) {
      this.orderOpen = false
    }
  }
}
</script>

I can hear you already: “That’s a lot of Tailwind CSS…”, I know but we’re bootstrapping 😉. Let’s look at what we care about:

In computed() we’re grabbing the current state of the three filters we care about: searchstatus, and order. And making our orders readable since we made them === key on the lead.

Our methods() are all very straight forward and only dispatch the actions we created earlier. It’s all reactive and gets handled by Vuex!

Lead List

This is our index page listing all of our leads

// ~/pages/leads/index.vue
<template>
  <div id="lead-index-wrapper" class="container pt-4 px-2 w-full md:w-2/3 lg:w-1/2 xl:w-1/3">
    <div>
      <div v-if="leads.length">
        <LeadFilter />
        <nuxt-link v-for="lead in filteredLeads" :key="lead.id" :to="'/leads/' + lead.id">
          <IndexCard :lead="lead" />
        </nuxt-link>
        <NoLeadsCard v-if="!filteredLeads.length" />
      </div>
      <OnboardingCard v-if="!leads.length" />
    </div>
  </div>
</template>

<script>
import { mapGetters } from 'vuex'
import LeadFilter from '~/components/leads/leadFilter'
import IndexCard from '~/components/leads/IndexCard'
import OnboardingCard from '~/components/leads/onboardingCard'
import NoLeadsCard from '~/components/leads/noLeadsCard'
export default {
  middleware: 'authenticated',
  components: { IndexCard, NoLeadsCard, OnboardingCard, LeadFilter },
  computed: {
    ...mapGetters({
      'leads': 'leads/getLeads',
      'filteredLeads': 'leads/getFilteredLeads',
      'lead': 'leads/getLead'
    })
  },
  async fetch ({ store }) {
    await store.dispatch('leads/fetchAllLeads')
  },
  mounted () {
    if (!this.leads.length) {
      this.$store.dispatch('leads/fetchAllLeads')
    }
  }
}
</script>

Not everything here is relevant to this guide, but let’s take a look at what’s happening on the front end.

As you can see, besides checking that leads exist, most of our components only care about the filteredLeads which initially are same-same as leads.

We import our LeadFilter component which is really dumb and only cares about the state in our Vuex store.

Wrapping up

That’s it, we’ve seen how we can use actions to commit mutations and dispatch other actions. We talked a bit about sorting() and using includes() in javascript. And mostly, I wanted to demonstrate how to use state to prevent passing multiple arguments to each method and keeping a single source of truth.

I’ve really enjoyed working with Nuxt and diving deeper into state management using Vuex. I have learned so much over the past couple months and wanted to give back.

Leave a Reply

Your email address will not be published. Required fields are marked *