Crisp is a javascript library that enables advanced search and filtering capabilities for Shopify themes. It moves the filtering client-side which enables some cool stuff like
- Filtering collections by tags, metafields, or anything else!
- Filtering search results by tags, metafields, etc
- Creating a searchable collection
- View Crisp in action:
- View on the Demo Store
Crisp adds a lot of complexity to a Shopify collection page. In many cases it won't be necessary and could cause headaches. That said, if you are just excited to try Crisp skip down to getting started
It is already easy for your customers to find what they are looking for, there is no need to intimidate them with a bunch of advanced filtering options. Just Shopify's build in Search and Tag filtering should be more than enough.
You don't need Crisp to accomplish this. It is overkill. If this is your goal then consider writing your own templates and making the ajax requests yourself. It also makes it easier to account for some of the seo concerns
Crisp does its best to optimize for performance but it can only take it so far. If you are wanting to filter thousands of products without narrowing down the selection first (think /collections/all) Crisp may take too long to return results. In this case consider restructuring your "funnel" if you want to use Crisp, or use an app instead.
Crisp is made up of two components, the client and the template. The client is is a Javascript library that allows (relatively) easy access to the filtered data. The second component is the template installed in the Shopify theme which gives the client access to raw unfiltered data.
Crisp relies on the fairly well-known technique of using Liquid templates to create pages that can be used as JSON endpoints. This allows the client to load and filter data.
The template is simply a secondary template file in your theme (collection.crisp.liquid for example) that will never be seen when viewing the website normally. It starts with the line {% layout none %} which tells Shopify not to include the normal framing content of your site (header, tracking scripts, etc.). It then uses Liquid to build a JSON blob that the client will be able to recognize.
The client will be able to make fetch or ajax calls to access this JSON data. This can be done by changing the view query parameter to match the name of the template (example.com?view=crisp for example).
See the /templates folder for some pre-populated examples of templates.
The client is where the "magic" happens. This is where all of the data loading, filtering, and configuration lives.
At its most basic, you ask the client to get you some, for example, products from the shoes collection. The client will load data from the template url (/collections/shoes?view=crisp), process it, and return to you to display to the user.
This gets more complicated when you ask tougher questions. If this time you want Size 9 or 10 running shoes in pink or purple things get a little more complicated under the hood but the interface you communicate with remains the same.
To get a little bit more into it, Crisp tries to find a balance between performance and resource usage while loading and filtering. This involves making some educated guesses in terms of how many shoes to load immediately and cancelling any extraneous requests made from the guesses as quickly as possible. Of course there are still cases where there is only one item that matches the filter constraints and it is the very last one, but in most cases Crisp works quite quickly.
TODO
First up, we need to decide which template(s) you will need to install. This will depend on which features of Crisp you are planning to use. For example, SearchableCollection will require a Collection template and a Search template. If you are unsure which templates are required for a given feature see the corresponding entry in the API Documentation and it will list the required templates.
Now, knowing which templates we will be installing, head over to the /templates directory locate the example files that will need to be installed. Simply copy these files to your theme's /templates directory and we are ready to move on
You may have noticed a strange suffix on all the templates (ex.
__DO-NOT-SELECT__.products). While this is by no means required, keep in mind that just like any alternate template this will show up in the Shopify Admin as an option for how to display a resource on the storefront. We never want to display this template by default so the underscore prefix ensures it gets pushed to the bottom of the list and, well, the "DO-NOT-SELECT" speaks for itself.
There are a number of ways to install the client depending on your bundler or toolchain. For this guide however, we will be simply adding it as a script tag.
Head over to the latest release and download crisp.js. Next, upload this to your themes /assets folder. Now we are ready to import it from the theme.
As with any dependency, it is good practice to only import resources on pages where they are required. For this example however, we will just be adding a global import in
theme.liquid.
To import Crisp, add <script type="text/javascript" src="{{ 'crisp.js' | asset_url }}"> in the <head> of your theme.liquid.
Now, to test it out and make sure everything is working correctly we can use the developer console. Navigate to your storefront, open the developer tools (F12 generally), then head to the console tab.
We can now make sure the client has been installed correctly by running
Crisp.VersionIt should print a version number corresponding to the latest release (or whichever version you are using)
Next, we can test out loading and filtering some resources. The exact code you will need to run will depend on which templates you installed by here is an example of loading products from a collection template in the console
{
// Initialize the collection
const collection = Crisp.Collection({
handle: 'all',
template: '__DO-NOT-SELECT__.products',
});
// Load first 10 products
const products = await collection.get({
number: 10,
});
// Print them to the console
console.log(products);
}- Crisp.Collection
- Collection
- CollectionOrder
- Crisp.Search
- Search
- SearchType
- SearchField
- Crisp.SearchableCollection
- SearchableCollection
- Crisp.Filter
- Filter
- FilterModel
- FilterEventCallback
- Version
- isCancel
- FilterFunction
- Payload
- Callback
Make sure the collection template has been added and modified to suit your needs
// Create a new instance
const collection = Crisp.Collection({
handle: 'all', // REQUIRED
template: '__DO-NOT-SELECT__.products', // REQUIRED
});
// Get the first 10 products
collection.get({
number: 10,
callback: function(response) {
// Handle error
if (response.error) {
// Check if due to cancellation
if (Crisp.isCancel(response.error)) {
return;
}
// Non cancellation error
throw error;
}
// Use products
console.log(response.payload);
}
});Creates a collection instance
configObjectconfig.handlestringconfig.templatestringconfig.orderCollectionOrder (optional, defaultvoid)config.filterFilterFunction (optional, defaultvoid)
const collection = Crisp.Collection({
handle: 'all',
template: '__DO-NOT-SELECT__.products',
});Returns CollectionInstance
handlestring
collection.setHandle('all');filterFilterFunction
collection.setFilter(function(product) {
return product.tags.indexOf('no_show' === -1);
});orderCollectionOrder
collection.setOrder('price-ascending');Clears the internal offset stored by getNext
collection.clearOffset();Manually cancel active network requests
collection.cancel();Retrieve the first options.number products in a collection. No filter support but extremely fast
collection.preview({
number: 10,
});Returns Promise<(Payload | void)>
The most versatile option for retrieving products. Only recommended for use cases that require a large amount of customization
optionsObject
collection.get({
number: 10,
});const payload = await collection.get({
number: 10,
offset: 10,
});Returns Promise<(Payload | void)>
Similar to get but stores and increments the offset internally. This can be reset with calls to getNext. Recommended for infinite scroll and similar
collection.getNext({
number: 10,
});Returns Promise<(Payload | void)>
- See: shopify sort order
Defines in what order products are returned
Type: ("default" | "manual" | "best-selling" | "title-ascending" | "title-descending" | "price-ascending" | "price-descending" | "created-ascending" | "created-descending")
Make sure the search template has been added and modified to suit your needs
// Create a new instance
const search = Crisp.Search({
query: 'apple', // REQUIRED
template: '__DO-NOT-SELECT__', // REQUIRED
});
// Get the first 10
search.get({
number: 10,
callback: function(response) {
// Handle error
if (response.error) {
// Check if due to cancellation
if (Crisp.isCancel(response.error)) {
return;
}
// Non cancellation error
throw error;
}
// Use products
console.log(response.payload);
}
});Creates a search instance
configObjectconfig.querystringconfig.templatestringconfig.filterFilterFunction (optional, defaultvoid)config.typesArray<SearchType> (optional, default["article","page","product"])config.exactboolean (optional, defaulttrue)config.andboolean (optional, defaulttrue)config.fieldsArray<SearchField> (optional, default[])
const collection = Crisp.Search({
query: 'blue shirt',
template: '__DO-NOT-SELECT__',
});Returns SearchInstance
querystring
search.setQuery('blue shirt');filterFilterFunction
search.setFilter(function(object) {
return object.type === 'product';
});typesArray<SearchType>
search.setTypes(['product']);exactboolean
search.setExact(false);andboolean
search.setAnd(false);fieldsArray<SearchField>
search.setTypes(['title', 'author']);Clears the internal offset stored by getNext
search.clearOffset();Manually cancel active network requests
search.cancel();search.preview({
number: 10,
});Returns Promise<(Payload | void)>
optionsObject
search.get({
number: 10,
});const payload = await search.get({
number: 10,
offset: 10,
});Returns Promise<(Payload | void)>
search.getNext({
number: 10,
});Returns Promise<(Payload | void)>
Type: ("article" | "page" | "product")
Type: ("title" | "handle" | "body" | "vendor" | "product_type" | "tag" | "variant" | "sku" | "author")
Make sure the collection template has been added and modified to suit your needs
Make sure the search template has been added and modified to suit your needs
If you plan to use the all collection in shopify it must have been created in the shopify admin. Otherwise nothing will be returned when searching within the all collection. The easiest conditions for the collection are price != 0 || price == 0.
// Create a new instance
const collection = Crisp.SearchableCollection({
handle: 'all', // REQUIRED
collectionTemplate: '__DO-NOT-SELECT__.products', // REQUIRED
searchTemplate: '__DO-NOT-SELECT__', // REQUIRED
});
// Get the first 10 products
collection.get({
number: 10,
callback: function(response) {
// Handle error
if (response.error) {
// Check if due to cancellation
if (Crisp.isCancel(response.error)) {
return;
}
// Non cancellation error
throw error;
}
// Use products
console.log(response.payload);
}
});Creates a collection instance
configObjectconfig.handlestringconfig.collectionTemplatestringconfig.searchTemplatestringconfig.filterFilterFunction (optional, defaultvoid)config.orderCollectionOrder Order only works whilequery === ''(optional, defaultvoid)config.exactboolean (optional, defaulttrue)config.andboolean (optional, defaulttrue)config.fieldsArray<SearchField> (optional, default[])
const collection = Crisp.SearchableCollection({
handle: 'all',
collectionTemplate: '__DO-NOT-SELECT__.products',
searchTemplate: '__DO-NOT-SELECT__',
});Returns SearchableCollectionInstance
handlestring
collection.setHandle('all');querystring
collection.setQuery('blue shirt');filterFilterFunction
collection.setFilter(function(product) {
return product.tags.indexOf('no_show' === -1);
});Order only works while query === ''
orderCollectionOrder
collection.setOrder('price-ascending');exactboolean
collection.setExact(false);andboolean
collection.setAnd(false);fieldsArray<SearchField>
collection.setTypes(['title', 'author']);Clears the internal offset stored by getNext
collection.clearOffset();Manually cancel active network requests
collection.cancel();optionsObject
collection.get({
number: 10,
});const payload = await collection.get({
number: 10,
offset: 10,
});Returns Promise<(Payload | void)>
Similar to get but stores and increments the offset internally. This can be reset with calls to getNext. Recommended for infinite scroll and similar
collection.getNext({
number: 10,
});Returns Promise<(Payload | void)>
Crisp.Filter is available as of version 4.1.0
While using SearchableCollection I noticed that the majority of my javascript was dealing with keeping the filter ui and the filter function in sync. Everything felt a little awkward to I took some inspiration from flux and designed Crisp.Filter
Crisp.Filter allows writing filters in a declarative manner and then handles generating a filter function and firing appropriate events.
Crisp.Filter uses a tree internally to efficiently keep the filter state in sync. This "filter tree" needs to be provided at instantiation so Crisp.Filter can fire initial events and build the first filter function.
The filter tree is made up of nodes with children. For example the simplest node looks something like:
{
name: 'my-unique-name',
}The name must be unique to each node. To generate a tree like structure we can use the children property
{
name: 'parent',
children: [{
name: 'child-1',
}, {
name: 'child-1',
}],
}This isn't particularly useful, but now we can start adding in filter functions. Note that Crisp.Filter's filters prop takes an array of nodes. The root node is handled internally.
const filter = Crisp.Filter({
filters: [{
name: 'red',
filter: payload => payload.color === 'red',
}, {
name: 'blue',
filter: payload => payload.color === 'blue',
selected: true,
}],
});
['red', 'blue', 'yellow'].filter(filter.fn());
// ['blue']There is a lot to unpack there. First off, each node can have a filter property. This is a function that takes in the original payload (whatever you are filtering) and returns a boolean (like Array.filter).
Notably, the filter property is ignored unless selected is also set to true. We will get more into that later.
Once a Filter instance is created, .fn() can be called to create a filter function based on the current state of the internal tree. In this case only the 'blue' node's filter is selected so only 'blue' makes it through.
Lets say, for example, that we want to filter based on the size or color of shirts. In this case size and color will be parent nodes and the options will be children
const filter = Crisp.Filter({
filters: [{
name: 'color',
children: [{
name: 'red',
filter: shirt => shirt.color === 'red',
selected: true,
}, {
name: 'blue',
filter: shirt => shirt.color === 'blue',
}],
}, {
name: 'size',
children: [{
name: 'small',
filter: shirt => shirt.size === 'small',
selected: true,
}, {
name: 'medium',
filter: shirt => shirt.size === 'medium',
selected: true,
}, {
name: 'large',
filter: shirt => shirt.size === 'large',
}],
}],
});This will now generate a function whose logic can be expressed like
(size 'small' OR 'medium') AND color 'red'
This is because the root node enforces logical AND on its children by default while all other nodes enforce logical OR. This can be overwridden by using the and property of a node or by passing and: boolean into the Filter options.
Selecting & Deselecting filters is very easy. Selection can be declared during instantiation but during runtime it is as simple as
filter.select('blue');
filter.deselect('small');For those working with hierarchies there is also a helper for nodes with children which will deselect all (immediate) children
filter.clear('size');Generally these methods will be called from event handlers. For example when someone clicks on a filter.
Crisp.Filter instances emit events that can be subscribed to with the .on() method
filter.on('update', node => { /* DO STUFF */ });Currently, the only event type is 'update' which fires anytime a nodes selected state changes.
This is where filter UI updates should occur.
Crisp.Filter also makes syncing the url parameters and filters very easy. Calling filter.getQuery() returns a comma delimited string of the currently selected filter names. Conversely, providing filter.setQuery(string) with that same string (say on page reload) will select the correct filters and fire corresponding events.
Creates a new filter object
configobjectconfig.filtersArray<FilterModel>config.globalArray<FilterFunction> (optional, default[])config.andboolean (optional, defaulttrue)
const filter = Filter({
global: [
color => typeof color === 'string',
],
filters: [
{
name: 'blue',
filter: color => color === 'blue',
},
{
name: 'red',
filter: color => color === 'red',
selected: true,
},
{
name: 'yellow',
filter: color => color === 'yellow',
},
],
and: false,
});Returns FilterInstance
Selects a given node in the filter tree
namestring
filter.select('blue');Returns boolean Success
Deselects a given node in the filter tree
namestring
filter.select('blue');Returns boolean Success
Deselects all children of a given node
namestring
filter.clear('color');Returns boolean Success
Returns the context of a given node
namestring
Returns any context
Generates a filter function based on the current state of the filter tree
[1, 2, 3].filter(filter.fn());Returns FilterFunction
Returns a comma delimited string of the selected filters
filter.getQuery();
// red,yellowReturns string
Takes a query are and select the required filters
querystringstring
filter.setQuery('red,yellow');The update event
eventNamestringcbFilterEventCallback
filter.on('update', ({ name, parent, selected, context }) => {
// Update filter ui
});Type: {name: string, filter: FilterFunction?, exclusive: boolean?, and: boolean?, selected: boolean?, context: any?, children: Array<FilterModel>?}
namestringfilterFilterFunction?exclusiveboolean?andboolean?selectedboolean?contextany?childrenArray<FilterModel>?
The event callback
Type: Function
optionsobject
Active version of Crisp
console.log(Crisp.Version);
// 0.0.0A function to determine if an error is due to cancellation
errorerror
const cancelled = Crisp.isCancel(error);Returns boolean
Accepts an api object and returns whether to keep or remove it from the response
Type: function (any): boolean
An array of the requested api object. Generally based on a template
Type: Array<any>
A callback function that either contains the requested payload or an error. Remember to check if the error is due to cancellation via isCancel
Type: function ({payload: Payload?, error: Error?}): void
argsobject
collection.get({
number: 48,
callback: function callback(response) {
var payload = response.payload;
var error = response.error;
if (Crisp.isCancel(error)) {
// Can usually ignore
return;
}
if (error) {
// Handle error
return;
}
// Use payload
}
});Returns undefined
