Designing the code behind product filters
You’ve seen these all over the internet, a sidebar containing controls that can filter a list, usually products in an online store. At my job as a front-end software engineer, we also made one of these, and I’d like to share with you how it works.
Our system needs to filter on a few different list types like customers, orders and more. So it needed to be generic in the type of user input it can handle and which filters it displays.
Here’s a basic example of a configuration object, with only one filter:
{
filters: {
release_date: {
field: "release_date",
label: "Release Date",
inputType: "radio",
options: {
"last-30-days": { label: "Last 30 days" },
"last-90-days": { label: "Last 90 days" },
"last-7-days": { label: "Last 7 days" },
},
keys: [
"last-7-days",
"last-30-days",
"last-90-days"
],
},
},
keys: ["release_date"],
}
There are two main parts to the top level of this object, filters
which contains a map of filter definitions, and keys
which serves two purposes:
- It provides performant iterative access to the filters when listing them in the front-end UI.
- It allows the order of the filters to be specified, since object keys cannot be relied upon to be in the correct order when sent from our back-end API via JSON.
Having both filters as a map of ID to definition and keys as an array of filter IDs gives us the best of both worlds when accessing filters. Displaying them as a list in the UI or performing some transformation? Use the keys
array. Wanting to access a single filter? Use direct access with filters[id]
.
A good example of that second one is when you are applying the user’s selected filters from the URL. For instance, /list?release_date=last-7-days
you could easily know release_date
is the selected filter and show that in the UI. This system lets us have human-readable query parameters pretty easily.
Below is an example of the markup you could write with this config. I’ve chosen AngularJS syntax because it’s easy to understand. Element attributes that start with ng-
are logic statements and words wrapped in {{ }}
are interpolated values.
<div
ng-repeat="filterKey in config.keys"
ng-init="filter = config.filters[filterKey]">
<!-- Filter Heading -->
<header>{{ filter.label }}</header>
<!-- Filter Options -->
<div ng-switch="filter.inputType">
<div ng-switch-when="radio">
<label ng-repeat="option in filter.keys">
<input
type="radio"
name="{{ filter.field }}"
ng-value="option"
ng-model="filterModel[filter.field]">
<span>{{ filter.options[option].label }}</span>
</label>
</div>
</div>
</div>
To store what the user has selected we’re using this line:
ng-model="filterModel[filter.field]"
The filterModel
is a simple map of filter.field
to input value, which also maps great to URL query parameters. For example, our /list?release_date=last-7-days
becomes { release_date: 'last-7-days' }
.
So, what if we want something more complex? Well, you could add more input types for one. For example, you could create a custom component for entering a monetary value, made up of a number and a currency.
<div ng-switch-when="money">
<money-filter
model="filterModel[filter.field]"
currencies="filter.options"
filter-field-name="filter.field">
</money-filter>
</div>
Now options
becomes the available currencies. As long as the value can be serialised as a string, it can be put into our model. In this case, we could do <value>,<currency>
e.g. 42.05,USD
and the filter component knows how to split the model value into two parts.
Another desired behaviour is having a “custom” input value, such as in our “Release Date” filter above we could have a date picker as a fourth radio button option.
This can be achieved by providing a custom
property to the filter definition:
{
field: "release_date",
custom: {
inputType: "date",
},
...
This new custom
object could contain fields that the custom inputType
requires, like min and max values or another label etc. The markup for this could be placed at the bottom of the list of options:
<div ng-switch="filter.inputType">
<div ng-switch-when="radio">...</div>
...
</div>
<div ng-if="filter.custom">
<div ng-switch="filter.custom.inputType">
<!-- Custom inputs here -->
</div>
</div>
And the same rule applies where the model must be a string. We’ll know if it’s custom instead of one of our pre-made options because the custom value will not appear there. In our code, filter.options[filterModel[filter.field]]
will be undefined.
This structure has worked out well for us at work, and allowed us to add new properties and features fairly easily. For example, you could group filters by adding an array of categories and specifying which filter keys belong to each category, essentially splitting keys
into smaller groups instead of one long array.
Thanks for reading!