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.Version
It 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
config
Objectconfig.handle
stringconfig.template
stringconfig.order
CollectionOrder (optional, defaultvoid
)config.filter
FilterFunction (optional, defaultvoid
)
const collection = Crisp.Collection({
handle: 'all',
template: '__DO-NOT-SELECT__.products',
});
Returns CollectionInstance
handle
string
collection.setHandle('all');
filter
FilterFunction
collection.setFilter(function(product) {
return product.tags.indexOf('no_show' === -1);
});
order
CollectionOrder
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
options
Object
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
config
Objectconfig.query
stringconfig.template
stringconfig.filter
FilterFunction (optional, defaultvoid
)config.types
Array<SearchType> (optional, default["article","page","product"]
)config.exact
boolean (optional, defaulttrue
)config.and
boolean (optional, defaulttrue
)config.fields
Array<SearchField> (optional, default[]
)
const collection = Crisp.Search({
query: 'blue shirt',
template: '__DO-NOT-SELECT__',
});
Returns SearchInstance
query
string
search.setQuery('blue shirt');
filter
FilterFunction
search.setFilter(function(object) {
return object.type === 'product';
});
types
Array<SearchType>
search.setTypes(['product']);
exact
boolean
search.setExact(false);
and
boolean
search.setAnd(false);
fields
Array<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)>
options
Object
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
config
Objectconfig.handle
stringconfig.collectionTemplate
stringconfig.searchTemplate
stringconfig.filter
FilterFunction (optional, defaultvoid
)config.order
CollectionOrder Order only works whilequery === ''
(optional, defaultvoid
)config.exact
boolean (optional, defaulttrue
)config.and
boolean (optional, defaulttrue
)config.fields
Array<SearchField> (optional, default[]
)
const collection = Crisp.SearchableCollection({
handle: 'all',
collectionTemplate: '__DO-NOT-SELECT__.products',
searchTemplate: '__DO-NOT-SELECT__',
});
Returns SearchableCollectionInstance
handle
string
collection.setHandle('all');
query
string
collection.setQuery('blue shirt');
filter
FilterFunction
collection.setFilter(function(product) {
return product.tags.indexOf('no_show' === -1);
});
Order only works while query === ''
order
CollectionOrder
collection.setOrder('price-ascending');
exact
boolean
collection.setExact(false);
and
boolean
collection.setAnd(false);
fields
Array<SearchField>
collection.setTypes(['title', 'author']);
Clears the internal offset stored by getNext
collection.clearOffset();
Manually cancel active network requests
collection.cancel();
options
Object
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 node
s 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 node
s 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 node
s 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 node
s 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
config
objectconfig.filters
Array<FilterModel>config.global
Array<FilterFunction> (optional, default[]
)config.and
boolean (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
name
string
filter.select('blue');
Returns boolean Success
Deselects a given node in the filter tree
name
string
filter.select('blue');
Returns boolean Success
Deselects all children of a given node
name
string
filter.clear('color');
Returns boolean Success
Returns the context of a given node
name
string
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,yellow
Returns string
Takes a query are and select the required filters
query
stringstring
filter.setQuery('red,yellow');
The update event
eventName
stringcb
FilterEventCallback
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>?}
name
stringfilter
FilterFunction?exclusive
boolean?and
boolean?selected
boolean?context
any?children
Array<FilterModel>?
The event callback
Type: Function
options
object
Active version of Crisp
console.log(Crisp.Version);
// 0.0.0
A function to determine if an error is due to cancellation
error
error
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
args
object
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