Skip to Main Content

A Front-End Web Development Blog by Kristen Grote.

Headless Craft CMS with GraphQL and React, Part 8: Adding Category Filtering

Watch a Video of this Tutorial

Our headless React-Craft CMS site is nearing completion! The last UX feature we're going to impliment is allowing the user to filter recipes by dietary restriction. To do this we will query the Craft back-end for a list of categories, populate them as checkboxes on our React front-end, and trigger an Apollo refetch when a checkbox is toggled. Let's dive in!

Create a Category Filters GraphQL Query

We're pulling in our categories from our Craft "Dietary Restrictions" category group, so we need to write another GraphQL query to request the category data. Create a new file in your /data folder called category-filters-query.js and paste in the following:

import { gql } from "@apollo/client";

const GET_CATEGORY_FILTERS = gql`
    query GetCategoryFilters($group: [String]) {
        categories(group: $group) {
            id
            title
            slug
        }
    }
`;

export default GET_CATEGORY_FILTERS;

By now you're probably familiar with what's happening here. We're querying our schema for categories and filtering using the group variable, and we'll return the category id, title, and slug.

Call the Query Inside the Category Filter Component

Open up your /components/CategoryFilter.js component and call the query using Apollo's useQuery hook,  just like we did for the recipe listing and single entry template:

import { useQuery } from "@apollo/client/react";
import GET_CATEGORY_FILTERS from "@/data/category-filters-query";

export default function CategoryFilter() {
    const queryVariables = {
        group: ['diet']
    }

    const { data, error } = useQuery(GET_CATEGORY_FILTERS, { variables: queryVariables });

    if (error) {
        console.error(error);
        return null;
    }

    if (!data) return null;

    return (
        <div className="category-filter">
            {data.categories.map( (cat) => (
                <div className="fieldgroup" key={cat.id}>
                    <input 
                        type="checkbox" 
                        id={'cat-' + cat.slug} 
                        name={'cat-' + cat.slug} 
                        value={cat.id}
                    />

                    <label htmlFor={'cat-' + cat.slug}>{ cat.title }</label>
                </div>
            ) )}
        </div>
    );
}

We loop through the returned data and create a checkbox input for each result. If we add new categories in Craft they will be automatically populated on the front-end. 

Add the relatedTo Variable to the Entries Query

Before we add event handling to our checkboxes we need to create a new filter variable for our recipe entries query. Open up /data/recipe-entries-query.js and add Craft's relatedTo variable to the query:

const GET_RECIPE_ENTRIES = gql`
    query GetRecipeEntries(
        $section: [String]
        $limit: Int
        $offset: Int
        $search: String
        $orderBy: String
        $relatedTo: [QueryArgument] # <-- add this
    ) {
        entries(
            section: $section
            limit: $limit
            offset: $offset
            search: $search
            orderBy: $orderBy
            relatedTo: $relatedTo # <-- add this
        ) {
            # ...
        }
        entryCount(
            section: $section
            search: $search
            orderBy: $orderBy
            relatedTo: $relatedTo # <-- add this
        )
    }
`;

Just like with our search query this will enable us to pass an additional filter parameter to our Craft entries query and get an accurate entryCount for pagination. You'll notice that $relatedTo has a type of [QueryArgument] because Craft's relatedTo parameter accepts more than just strings or numbers (more on that later).

Add a relatedTo Variable to the Query Key Argument

We've introduced a new filtering option to our query, which means there are now more possible cache combinations:

  • section only
  • section + search query
  • section + category filter
  • section + search query + category filter

We need to expand our cache key options so that all of these different combinations are stored as a separate cache. Open /data/query-cache.js and add the relatedTo variable to the listingKeyArgs array:

const listingKeyArgs = ['section', 'search', 'relatedTo']; // <-- add relatedTo

const queryCache = new InMemoryCache({
    typePolicies: {
        Query: {
            fields: {
                entries: offsetLimitPagination(listingKeyArgs),
                entryCount: {
                    keyArgs: listingKeyArgs
                }
            },
        },
    },
});

Create a relatedTo Apollo Reactive Variable

Just like with our search query, whenever a new category filter is selected we want to signal Apollo to re-fetch the query with the new parameters. To do that we're going to create another Reactive Variable on our listing page (/pages/index.js in Next.js):

const searchQuery = makeVar(undefined);
const queryRelatedTo = makeVar(undefined); // <-- new reactive variable

export default function Home() {
    const queryOffset = useRef(0);
    const queryOrderBy = useRef(undefined);

    const queryVariables = {
        section: ["recipes"],
        limit: 4,
        offset: queryOffset.current,
        search: useReactiveVar(searchQuery),
        orderBy: queryOrderBy.current,
        relatedTo: useReactiveVar(queryRelatedTo) // <-- apply reactive variable to query
    };

    const { error, data, fetchMore } = useQuery(GET_RECIPE_ENTRIES, { variables: queryVariables });

    // ...
}

As with our search query if no category filters exist we want the variable to be completely undefined to prevent any filtering or caching conflicts. 

Write an onChange Event Handler for the Category Checkboxes

Now that we have a state variable we can write an event handler to update its value whenever a checkbox is toggled:

export default function Home() {
    // ...

    const handleCategoryChange = (e) => {
        // create a new array if one doesn't exist
        // Craft category queries begin with the 'and' operator
        let checkedCategories = queryRelatedTo() ? [...queryRelatedTo()] : ['and'];
        
        // category IDs must be explicitly cast as numbers
        const inputValue = Number(e.target.value);

        if(e.target.checked) {
            // add value if not already in array
            if (!checkedCategories.includes(inputValue)) {
                checkedCategories.push(inputValue);
            }
        } else {
            // remove value if it exists in array
            const index = checkedCategories.indexOf(inputValue);
			
            if(index > -1) {
                checkedCategories.splice(index, 1);
            }
        }

        // if there are no category filters, unset the variable
        if(checkedCategories.length == 1 && checkedCategories.includes('and')) {
            checkedCategories = undefined;
        }
        
        queryRelatedTo(checkedCategories);
		queryOffset.current = 0;
    };

    return (
        // ...

        <CategoryFilter 
            handleChange={handleCategoryChange}
            checkedCats={queryRelatedTo() || []}
        />

        // ...
    );
}

First, we check if the queryRelatedTo variable already has a value (remember that to read the value of an Apollo Reactive Variable you call it as a function, hence queryRelatedTo()). If it does, we make a copy of it. If it doesn't, we create a new array to store the values. But what's that 'and' item for?

For our situation, we want to return entries if ALL the checked categories are included (recipes that are BOTH gluten free AND vegan, for example). To do that, we apply Craft's 'and' operator to the beginning of the filter array. That's why the GraphQL type for the $relatedTo variable is [QueryArgument], it accepts both query operators as well as values. 

Next, we add the value of the checkbox to the array if it's checked, or remove it if it's unchecked. Checkbox values are returned as strings by default, so we need to explicitly recast them as numbers so they are read correctly by the relatedTo filter. 

We also do a check to reset queryRelatedTo to undefined if no category boxes are checked. 

Then we apply the value to the queryRelatedTo state variable which triggers a query refetch, remembering to reset the query's offset value at the same time.

Last, we pass the handler to the CategoryFilter component as well as the queryRelatedTo variable if it has a value, or an empty array if it doesn't (see below). 

Apply Event Handler and State Values to Checkbox Component

Back in /components/CategoryFilter.js we pass the event handler to the input's onChange event: 

export default function CategoryFilter({handleChange, checkedCats}) {

    // ...

    return (
        <div className="category-filter">
            {data.categories.map( (cat) => (
                <div className="fieldgroup" key={cat.id}>
                    <input 
                        type="checkbox" 
                        id={'cat-' + cat.slug} 
                        name={'cat-' + cat.slug} 
                        value={cat.id}
                        onChange={(e) => handleChange(e)} // handle check/uncheck event
                        checked={checkedCats.includes( Number(cat.id) )} // set as checked if it is included in the filter array
                    />

                    <label htmlFor={'cat-' + cat.slug}>{ cat.title }</label>
                </div>
            ) )}
        </div>
    );
}

We also apply a boolean value to the checked parameter depending on if this checkbox's category ID is included in the queryRelatedTo variable (that's why we have to explicitly pass an empty array to the component, because .includes() won't work on an undefined variable).

These checkboxes are ready! Open up the site in a browser and watch the listing automatically update when a box is checked. You can also combine filters by adding a search query. Neat!

Congratulations!

Take a moment to consider what you've achieved: you've built a website spanning two codebases using no less than FIVE programming languages! You've learned about how Craft CMS provides a robust GraphQL interface out-of-the-box with minimal configuration. You've learned how to use Apollo Client to streamline querying, refetching, and caching data. And you've seen the beginning of what's possible when combining these technologies with React. 

To deepen your knowledge, I encourage you to continue refining this recipe site on your own. How can you make it more accessible? How can you make it easier to use and more intuitive? What helpful features can you add? Good luck and have fun!

View the Project Code