Build an advanced search and filter with Vuex (in Nuxt)
Michael Messerli
Posted on December 6, 2019
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.
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:
- Search by company name and job title
- Show only leads in a certain status
- Order them by: created date, company name, job title, or status
- 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 filterStatus
, filterSearch
, 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.
setFilterStatus
, setFilterSearch
, 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 name, job title, status, 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: search, status, 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.
JobHuntBuddy
JobHuntBuddy.co
I used a project I'm currently working on as the example. Right now, I'm looking for a new job so this project is killing two bird by helping me manage my job hunt, and giving potential employers an open source code example to look at.
āļø Happy Coding!
Follow me on Twitter @michaelmesserli
Posted on December 6, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 22, 2020