Creating Favorites Using AJAX

Introduction

In Parts 1 and 2 of this series, we set up a search feature on our site where users could search and filter entries, and results were displayed on the page via AJAX. To wrap up our search experience for our users, we're going to add another helpful feature: favorites.

Favorites can be implemented in many forms, such as a favorites list, a wish list, a "save for later" list, or many other variations. For our example, we're simply going to allow the user to just have one favorites list to which they can add their favorite add-ons. To accomplish this, we're going to use EEHarbor's Favorites add-on and some AJAX techniques which we just learned in Part 2.

Building on what we've learned, this feature has a few requirements:

Great! Let's get started

In This Article

  1. Installing and Configuring Favorites Add-On
  2. Adding Favorites with AJAX
  3. All Favorites Listings
  4. Next Steps

Installing and Configuring Favorites Add-On

Favorites is a pretty robust add-on that allows users to save their favorite entries, create favorite lists, show the most popular entries on a website, and much more. In this article, we're only going to use a small percentage of Favorites' features. Let's get started by installing and configuring the add-on.

  1. Download and install Favorites.
  2. Add your license key in the add-on settings.

That's actually all you need to do for our needs. No need to create a collection or anything else.

Adding Favorites with AJAX

Now we're going to jump right into the thick of it. Using the amazing AJAX skills we learned in Part 2, we're going to allow users to save their favorites. To do this, we're going to give them a familiar-looking icon that is commonly recognized as a favorite icon, a heart. When the entry is a favorite, the heart icon will be solid in color ( ). When the entry is not a favorite, the heart icon will just be an outline ( ).

Adding Our Button

We're going to add the heart icon to our {partial_search_results} partial.

Current version of the partial from Part 2: https://raw.githubusercontent.com/ops-andy/eeu-search-favorite-tutorial/main/04-partial_search_results

Couple of notes:

  1. Add the button element with the heart icon.
        
    <div id="header" class="flex"> 
        <img alt="addon icon" class="rounded-md border-2 border-gray-300" style="width:100px;height:100px;" src="{add_on_icon}"/>
        <div id="body" class="flex flex-col ml-5">
            <h4 id="name" class="text-xl font-semibold mb-2">{title}</h4>
            <button class="w-1">
                {!-- notice that we're using the class "favorite-toggle" so we can query this element in the DOM later on --}
                <i class="far fa-heart favorite-toggle"></i>
            </button>
            <p id="job" class="text-gray-800 mt-2">{add_on_description:limit characters='100'}</p>
            <div class="flex mt-5">
                <p class="ml-3">
                    {if add_on_price == '0'}
                        Free
                    {if:else}
                        <sup>$</sup>{add_on_price}
                    {/if}
                </p>
            </div>
        </div>
    </div>
        
    
  2. Now add the CSS for our heart. You can do this either with a <style> element in your template or through an included CSS file.
                
    .fa-heart {
        background: 0 0;
        color: #ba1f3b;
        padding: 4px;
        cursor: pointer;
    }
                
            
  3. Finally, let's give our listing cards a class that we can easily use in our JavaScript to query the DOM and find our elements. We'll use the class addon-card.
                
    <div class=" flex flex-col my-1 px-1 w-full md:w-1/2 lg:my-4 lg:px-4 lg:w-1/3 addon-card">
        <div class="max-w-2xl bg-white border-2 border-gray-300 p-5 rounded-md tracking-wide shadow-lg flex-1">
        ...
        </div>
    </div>
                
            

Activating Our Heart

Next, let's bind an event listener to our heart button. When clicked, we'll add the class "active" and display a solid color heart. When clicked again, the heart will go back to just an outline.

We're eventually going to bind to the form's submit event to the heart icon, but it's good practice as we build on the concept to start small and refactor. So we'll start by binding to the click event.

We already have some JavaScript from our previous article that we are calling on the DOMContentLoaded event. We also know that, similar to our pagination, this event binding will have to take place every time we load new content to the DOM (think about the AJAX responses). Therefore we're going to put this in its own function and then call that function as needed.

  1. Let's pseudo-code this out
                    
    If user clicks heart and entry is not in favorites
        entry is added to their favorites list
        heart icon is filled in with color
    If user clicks heart and entry is already in their favorites
        entry is removed from their favorites
        heart icons color is removed leaving an outline
                    
                
  2. Since we're just focusing on the click event, we will not worry about adding the favorites list just yet.

                
    //addClicktoFavoritesToggle
    //toggles favorited items
    //must be called AFTER ajax calls are complete.
    function addClicktoFavoritesToggle () {
        //find all buttons in our add-on cards
        favoriteToggles = document.querySelectorAll('.addon-card button');
    
        favoriteToggles.forEach(favToggle => {
            favToggle.addEventListener('click', event => {
                var favHeart = favToggle.querySelector('.favorite-toggle');
    
                //We are using Font-awesome style prefixes to switch between the regular style ("far") and the solid style ("fas")
                //remove font-awesome classes, then add respective classes
                favHeart.classList.remove('fas');
                favHeart.classList.remove('far');
    
                //if the heart is already active then switch back to the outline only (regular) style. 
                if (favHeart.classList.contains('active')) {
                    favHeart.classList.remove('active');
                    favHeart.classList.add('far');
                }else{
                    favHeart.classList.add('active');
                    favHeart.classList.add('fas');
                }
            });
        });
    }
    
                
            
  3. Now, let's just update our DOMContentLoaded event to call our new function after results are loaded.
                
    //on load create AJAX request
    document.addEventListener('DOMContentLoaded', event =>{
        
        //submit the response with fetch()
        fetch(baseUrl() + "/ajax/addon-results")
            // take our response and get the HTML we really want
            .then(response => {
                return response.text();
                })
            // take our HTML and replace the contents of our #addon-results element
            .then(data => {
                resultsTarget.innerHTML = data;
                paginationLinks();
    
                //call our new function
                addClicktoFavoritesToggle();
            });
    });
                
            

    If all went as planned, your heart icons should now switch between the regular and solid styles when you click on them.

  4. As we know, we're not actually adding anything to our favorites list yet when the hearts are clicked. Next, let's explore how Favorites works and connect our hearts.

Understanding Favorites and Tags

There are two main actions that we want to be able to do with Favorites:


Special Notes:

  1. Let's look at the tags we'll need to connect Favorites.

    According to the docs, to create a form that will allow a user to add an entry to their favorites, we need to use the {exp:favorites:form} tag, and to remove an entry from a list of Favorites, we use the {exp:favorites:edit} tag.

    That's great if you're going to have different templates that display favorites vs non-favorites. However, for our needs, we need to flip-flop between an add-to favorites form and a remove-from favorites form in the same place and without reloading the page. Let's pseudo-code this:

                
    On page load
    If entry is already in favorites
        display edit tag with a solid heart
    If entry is not in favorites
        display form tag with an outlined heart
                
            
  2. The EEHarbor website has an example of how to do this using the {exp:favorites:info} tag: https://eeharbor.com/favorites/documentation/form#examples. So next we apply this to our {partial_search_results} partial.

    First, here is just the code for the heart button and our Favorites form.

                
    {!--favorites:info list info about an entry --}
    {exp:favorites:info
        entry_id="{entry_id}"
    }
        {!-- display the edit form if an entry is found in Favorites --}
        {exp:favorites:edit
            favorite_id="{favorites:favorite_id}"
            return="add-ons"
        }
            {!-- tells the edit form we want to delete the entry from favorites --}
            <input type="hidden" name="delete" value="yes">
            <input type="hidden" name="entry-id" value="{entry_id}">
            <button class="w-1" type="submit" value="Remove">
                <i class="fas fa-heart favorite-toggle active"></i>
            </button>
        {/exp:favorites:edit}
    
        {!-- if no entry is found in favorites, then it's not in a favorites list yet
        Thus display the favorites:form tag --}
        {if favorites:no_results}
            {exp:favorites:form
                entry_id="{entry_id}"
                return="add-ons"
            }
            <input type="hidden" name="entry-id" value="{entry_id}">
                <button class="w-1" type="submit" value="Add">
                    <i class="far fa-heart favorite-toggle"></i>
                </button>
            {/exp:favorites:form}
            
        {/if}
    {/exp:favorites:info}
                
            

    Now combine that with the rest of our entry listing in {partial_search_results}

                
    <div class="max-w-2xl bg-white border-2 border-gray-300 p-5 rounded-md tracking-wide shadow-lg flex-1">
        <div id="header" class="flex"> 
            <img alt="addon icon" class="rounded-md border-2 border-gray-300" style="width:100px;height:100px;" src="{add_on_icon}"/>
            <div id="body" class="flex flex-col ml-5">
                <h4 id="name" class="text-xl font-semibold mb-2">{title}</h4>
                {exp:favorites:info
                        entry_id="{entry_id}"
                        disable_pagination="yes"
                    }
                        {exp:favorites:edit
                            favorite_id="{favorites:favorite_id}"
                            return="add-ons"
                        }
                            <input type="hidden" name="delete" value="yes">
                            <input type="hidden" name="entry-id" value="{entry_id}">
                            <button class="w-1" type="submit" value="Remove">
                                <i class="fas fa-heart favorite-toggle active"></i>
                            </button>
                        {/exp:favorites:edit}
                        {if favorites:no_results}
                            {exp:favorites:form
                                entry_id="{entry_id}"
                                return="add-ons"
                            }
                            <input type="hidden" name="entry-id" value="{entry_id}">
                                <button class="w-1" type="submit" value="Add">
                                    <i class="far fa-heart favorite-toggle"></i>
                                </button>
                            {/exp:favorites:form}
                            
                        {/if}
                    {/exp:favorites:info}
                <p id="job" class="text-gray-800 mt-2">{add_on_description:limit characters='100'}</p>
                <div class="flex mt-5">
                    <p class="ml-3">
                        {if add_on_price == '0'}
                            Free
                        {if:else}
                            <sup>$</sup>{add_on_price}
                        {/if}
                    </p>
                </div>
            </div>
        </div>
    </div>
                
            
  3. Switching between forms on page load is a good start, but what about when a user clicks on our heart icon? In that case, the form itself should change. To plan this out, we'll grab our pseudo-code from the click event above and combine it with what we just wrote.
                    
    If user clicks heart and entry is not in favorites
        entry is added to their favorites list
        heart icon is filled in with color
        Favorites form changes from form tag to edit tag
    If user clicks heart and entry is already in their favorites
        entry is removed from their favorites
        heart icons color is removed leaving an outline
        Favorites form changes from edit tag to form tag.
                    
                

    This is all good, except we just have template tags to work with, and we can't reload template tags without reloading the page. Therefore we have to manipulate the DOM and change the form manually.


    To accomplish this, we will create the Favorites form in another template, then use AJAX to call out to that form each time a heart icon is clicked. This will render the respective Favorites tag in the other template and allow us to replace the form in our entry with the output of the other template. I promise that it sounds more confusing than it really is.


    Note: The first time I did this, I tried to just change the inputs and properties of the form on the fly. However, Favorites writes data to the database and then looks for that data when updating entries. Thus, we actually need to re-render the template tag for everything to work properly, and that is why using another template is the best method.

Connecting and Hijacking Favorites

We now understand a little more about what work we have to do. Let's roll up our sleeves and get started.

First, we're going to update our click event listener from above. We now are dealing with a form element (rendered by the Favorites template tags). This means that a click event won't cut it anymore. Similar to what we did to Low Search forms in previous lessons, we now need to hijack the form and submit it via AJAX.

  1. Refactor the click event to capture the form submission and prevent the default form action which would redirect the user to another page on success.
                
    //addClicktoFavoritesToggle
    //toggles favorited items
    //must be called AFTER ajax calls are complete.
    function addClicktoFavoritesToggle () {
        //notice we now are updating our selector to find the form, not the button
        favoriteToggles = document.querySelectorAll('.addon-card form');
    
        favoriteToggles.forEach(favToggle => {
            favToggle.addEventListener('submit', event => {
                //stop the user from being directed to the add-on page
                event.preventDefault();
    
                var favHeart = favToggle.querySelector('.favorite-toggle');
    
                //We are using Font-awesome style prefixes to switch between the regular style ("far") and the solid style ("fas")
                //remove font-awesome classes, then add respective classes
                favHeart.classList.remove('fas');
                favHeart.classList.remove('far');
    
                //if the heart is already active then switch back to the outline only (regular) style. 
                if (favHeart.classList.contains('active')) {
                    favHeart.classList.remove('active');
                    favHeart.classList.add('far');
                }else{
                    favHeart.classList.add('active');
                    favHeart.classList.add('fas');
                }
            });
        });
    }
                
            
  2. Now let's submit the form via AJAX using formData()
                
    //addClicktoFavoritesToggle
    //toggles favorited items
    //must be called AFTER ajax calls are complete.
    function addClicktoFavoritesToggle () {
        favoriteToggles = document.querySelectorAll('.addon-card form');
    
        favoriteToggles.forEach(favToggle => {
            favToggle.addEventListener('submit', event => {
    
                //stop the user from being directed to the add-on page
                event.preventDefault();
    
                var favHeart = favToggle.querySelector('.favorite-toggle');
    
                //make the ajax request to favorite the entry
                var formData = new FormData(favToggle);
                fetch(favToggle.action, {
                    method: 'post',
                    body: formData
                })
                .then(response => {
                    return response.text();
                    })
                .then(data => {
                
                    favHeart.classList.remove('fas');
                    favHeart.classList.remove('far');
    
                    //if the heart is already active then switch back to the outline only (regular) style. 
                    if (favHeart.classList.contains('active')) {
                        favHeart.classList.remove('active');
                        favHeart.classList.add('far');
                    }else{
                        favHeart.classList.add('active');
                        favHeart.classList.add('fas');
                    }
                });
            });
        });
    }
                
            
  3. Right now, you should be able to submit the form, have it sent over AJAX, and then the heart icon will now toggle regular/solid styles.

    This is good, except every click is just either continually adding to Favorites or removing from favorites (which will cause errors) depending on what the initial state of the form was. This is because we are not updating the form on successful AJAX responses.

    So now, we'll create our secret weapon, a Favorites form-only template. This template will render our heart icon and form based on the current status of a given entry. We'll grab the output of that form and replace the current form in the DOM.

    We'll call this template addon-favorites-form and put it in our "ajax" template group. This will allow us to access the template via the URL: /ajax/addon-favorites-form

                
    {exp:favorites:info
        entry_id="{segment_3}"
        disable_pagination="yes"
    }
        {exp:favorites:edit
            favorite_id="{favorites:favorite_id}"
            return="add-ons"
        }
            <input type="hidden" name="entry-id" value="{segment_3}">
            <input type="hidden" name="delete" value="yes">
            <button type="submit" value="Remove">
                <i class="fas fa-heart favorite-toggle active"></i>
            </button>
        {/exp:favorites:edit}
        {if favorites:no_results}
            {exp:favorites:form
                entry_id="{segment_3}"
                return="add-ons"
            }
            <input type="hidden" name="entry-id" value="{segment_3}">
                <button type="submit" value="Add">
                    <i class="far fa-heart favorite-toggle"></i>
                </button>
            {/exp:favorites:form}
            
        {/if}
    {/exp:favorites:info}
    
    

    As you can probably tell, this is the same form our results initially load with.

  4. Now that we have our form in our template, let's update the JavaScript to request the contents of that template via AJAX after a successful response when adding the selected entry to our Favorites.
                
    //addClicktoFavoritesToggle
    //toggles favorited items
    //must be called AFTER ajax calls are complete.
    function addClicktoFavoritesToggle() {
        favoriteToggles = document.querySelectorAll('.addon-card form');
    
        favoriteToggles.forEach(favToggle => {
            favToggle.addEventListener('submit', event => {
                
                //stop the user from being directed to the add-on page
                event.preventDefault();
    
                var favHeart = favToggle.querySelector('.favorite-toggle');
    
                //make the ajax request to favorite the entry
                var formData = new FormData(favToggle);
                fetch(favToggle.action, {
                    method: 'post',
                    body: formData
                })
                .then(response => {
                    return response.text();
                    })
                .then(data => {
                
                    favHeart.classList.remove('fas');
                    favHeart.classList.remove('far');
    
                    //if the heart is already active then switch back to the outline only (regular) style. 
                    if (favHeart.classList.contains('active')) {
                        favHeart.classList.remove('active');
                        favHeart.classList.add('far');
                    }else{
                        favHeart.classList.add('active');
                        favHeart.classList.add('fas');
                    }
                        //here we're grabbing the entry-id from the form's input
                        var entry = favToggle.querySelector('input[name="entry-id"]').value;
                        
                        //submit an AJAX request to our new form template
                        fetch('/ajax/addon-favorites-form/'+entry)
                            .then (response => {
                                return response.text();
                            })
                            .then (data => {
    
                                //now that we have a response. let's delete our current form and replace it with
                                //the html in our response
    
                                //we're about to delete the form we clicked on so we need
                                //another point of reference. This will select the <h4> previous
                                //to the form element
                                var formPositionSibling = favToggle.previousElementSibling;
    
                                //now lets remove our current form from the DOM
                                formPositionSibling.parentNode.removeChild(favToggle);
    
                                //next we insert our form from the AJAX response immediately 
                                // after our <h4> element.
                                formPositionSibling.insertAdjacentHTML('afterend',data);
    
                                //the form is new to the DOM, so we need to make sure The
                                // the new form's submit event has an event listener as well.
                                addClicktoFavoritesToggle();
                            });
                    });
            });
        });
    }
                
            
  5. The final step is to double-check that you are calling addClicktoFavoritesToggle();

Fantastic! You should now be able to load your page, click your heart icons to favorite/unfavorite entries, and click through searches and pagination with your favorites still saved. All this while remaining on the same page and no screen refreshes.

All Favorites Listings

Creating a template to display all of a user's favorite entries isn't really that hard. Favorites provides the {exp:favorites:entries} tag which makes listing these entries super easy. Of course, we don't want the user to have to navigate to a new URL, so we will use some more AJAX. Let's jump right in.

To keep ourselves DRY, we'd rather not repeat all the code we have in the {partial_search_results} partial. So we need to make one adjustment: moving our {if low_search_no_results} conditional from {partial_search_results} to our ajax/addon-results template. You can figure out where it goes, or check out the link

  1. We'll start by adding a new template in our "ajax" template group named "addon-favorites".
                
    {exp:favorites:entries
        limit='999'
        paginate='bottom'
        status="not closed"
    }
    
        {!-- if no results, let the user know --}
             {if no_results}
                    <div class="alert alert--empty">No add-ons have been added to your favorites.</div>
                {/if}
    
        
            {partial_search_results}
    
    
    {/exp:favorites:entries}
                
            
  2. Now we need a way for our users to see their favorites, so we'll add a simple link to the sidebar. When users click on this link, we'll make an AJAX request to our new template and return the results. This should be pretty simple based on the work we've already done.

    First is the link. We're adding this to the top of our partial_search_functions partial where the rest of the sidebar lives.
                
    div class="container my-12 mx-auto px-4 md:px-12 w-2 inline-block align-top">
    
        <a href="ajax/addon-favorites" id="see-all-favorites">View My Favorites</a>
        <hr>
    
        <div class="font-bold">Search All Add-Ons</div>
    
        ...
                
            
  3. Next, let's wrap it up with some JavaScript. Again, just put this wherever you've been putting your JavaScript up to this point.
                
    function showFavoriteResults() {
        //get results for users favorites
        document.getElementById("see-all-favorites").addEventListener('click', event => {
            //stop the user from being redirected
            event.preventDefault();
            fetch(document.getElementById("see-all-favorites").href)
            .then(response =>{
                return response.text();
            })
            .then(data => {
                resultsTarget.innerHTML = data;
                paginationLinks();
                addClicktoFavoritesToggle();
            });
        });
    }
                
            

    Be sure to add a call to our new function in our DOMContentLoaded event listener.

That's it! You should now have a fully working favorites form on each entry as well as a link that allows the user to see all their favorites

Next Steps

If you've followed all three parts of this series then congratulations! Here is a link to the final templates folder: https://github.com/ops-andy/eeu-search-favorite-tutorial/tree/main/final_template_folder.


There's still more you can do though! On expressionengine.com, we have a sidebar component that shows a user's 5 most recent favorited entries. There are also several JavaScript functions that could be combined to be a little more DRY. Same thing with templates. What else do you think would improve the user's experience, make our site more performant, or add clarity to the code?


Let me hear about how you implement this or new ways to do the same thing! I'd love to hear from you as we help grow the community together!

Comments 0

Be the first to comment!