Using AJAX with Low Search

Introduction

In Part 1 of this series, we set up our data in ExpressionEngine, installed Low Search, and created templates allowing our users to search and filter entries.

In this article, we're going to pick up where we left off as we improve the user's experience and begin introducing AJAX.

With AJAX, we'll have 2 goals:

Notes:

In This Article

  1. Moving and Refactoring Templates To Be Used By AJAX
  2. Writing Our JavaScript
  3. Ensuring Pagination Works With AJAX

Moving and Refactoring Templates To Be Used By AJAX

Since we're going to use AJAX to request data from our templates and then populate the DOM with the data from those templates, we no longer need a landing page and a separate results page. Our users will navigate to /addons and will remain at that URL throughout their searching. To accomplish this, we're going to have to do a little moving and refactoring of templates to keep things nice and organized.

  1. Create one template we can use that will give us our initial set of results, paginated results, and our search results after a search is submitted
  2. Refactor our addons/index template to handle responses from our AJAX calls

One Results Template

  1. Let's start by first creating a new template in a new template group we'll call "ajax." I like to do this so that I know what templates are only meant to be used with AJAX and keep those separate from other templates
  2. With our new "ajax" template group, we need a template that we'll call "addon-results". The URL for this template will be /ajax/addon-results and will be the URL we'll use when making AJAX requests to get search results.
  3. Now that we have our template, let's think about what data this template will need to provide:
    • An initial set of results (technically this could be AJAX or not)
    • Results when someone clicks on a pagination link from the initial results
    • Results from a submitted search
    • Results when someone clicks on a pagination link from the search results
    This sounds like a lot, so let's use some pseudo-code to plan out our ajax/addon-results template.
                
    if this is a request for initial set of results
        then send initial results set back
    
    if this is a request for search results
        read the query string and send back results of the search
    
    if this is a pagination request
        then send back next page of results based on current results
                
            

    Great, we've got the start of a plan. The next step is to determine how the template will know what we're requesting. Personally I like to do this with URL segments. So let's plan out our segments next.

    • If the template should be sending the initial set of results, then let's use the url /ajax/addon-results
    • If the template should be sending the results of a search, then Low Search will be appending our query to the end of the URL. Thus, our URL would look like /ajax/addon-results/[encoded query string]
    • If this is a paginated request, then ExpressionEngine uses the syntax P[page number] in the URL. Therefore, our URL will look like /ajax/addon-results/P[page number]
    • Finally, if this is a request for another page from a set of search results, then we'll have the encoded query string followed by the page number. So our URL will look like /ajax/addon-results/[encoded query string]/P[page number]

    Now, we have our plan for the template and the URLs that will be used to access our template. Let's update our template from pseudo-code, to fill in the URLs. We'll go ahead and start using template code and comments here.

    Note that Low Search results tag natively knows how to handle pagination, so we don't have to write separate conditionals for those.

                
    {if segment_3 =="" || segment_3~"#^P(\d+)$#"}
        {!-- this would match /ajax/addon-results and /ajax/addon-results/P[page number]--}
    
        {!-- @TODO: then send initial results set back or next page of initial results--}
    
    {if:else}
        {!-- 
        this would match both /ajax/addon-results/[encoded query string] and /ajax/addon-results/[encoded query string]/P[page number] 
            since Low Search results tag natively knows how to handle pagination we don't need to catch pagination separately here.
        --}
    
        {!-- @TODO: read the query string and send back results of the search or the next page from the results --}
    {/if}
                
            
  4. Now that we have the scaffolding of our results template, let's start to fill in the blanks. Thanks to ExpressionEngine parsing order (never thought I would say those words), we can use conditionals to have different opening {exp:low_search:result} tags.
    The first opening tag we need is for our initial results set. All we need here is the opening tag from our current addons/index template.
                
    {if segment_3 =="" || segment_3~"#^P(\d+)$#"}
        {!-- this would match /ajax/addon-results and /ajax/addon-results/P[page number]--}
    
        {exp:low_search:results
            channel='add_on_store'
            limit='4'
            paginate='bottom'
        }
    
    ...
                
            
    For our second opening tag, we again just need to use the opening tag from our current /addons/results template.
                
    {if segment_3 =="" || segment_3~"#^P(\d+)$#"}
        {!-- this would match /ajax/addon-results and /ajax/addon-results/P[page number]--}
    
        {exp:low_search:results
            channel='add_on_store'
            limit='4'
            paginate='bottom'
        }
    
    {if:else}
        {!-- 
        this would match both /ajax/addon-results/[encoded query string] and /ajax/addon-results/[encoded query string]/P[page number] 
            since Low Search results tag natively knows how to handle pagination we don't need to catch pagination separately here.
        --}
    
        {exp:low_search:results
            channel='add_on_store'
            limit='4'
            paginate='bottom'
            keywords:mode='all'
            keywords:loose='both'
            collection='add_on_store'
            query='{segment_3}' 
            dynamic_parameters='orderby|sort'
    
        }
    {/if}
    
    
    {!--
        @TODO send back HTML for results. This will include the cards and the pagination
    --}
    
    
    {/exp:low_search:results}
                
            
  5. Since we already have the HTML for our results in a partial, we just need to add that to complete the ajax/addon-results template.
                
    {if segment_3 =="" || segment_3~"#^P(\d+)$#"}
        {!-- this would match /ajax/addon-results and /ajax/addon-results/P[page number]--}
    
        {exp:low_search:results
            channel='add_on_store'
            limit='4'
            paginate='bottom'
        }
    
    {if:else}
        {!-- 
        this would match both /ajax/addon-results/[encoded query string] and /ajax/addon-results/[encoded query string]/P[page number] 
            since Low Search results tag natively knows how to handle pagination we don't need to catch pagination separately here.
        --}
    
        {exp:low_search:results
            channel='add_on_store'
            limit='4'
            paginate='bottom'
            keywords:mode='all'
            keywords:loose='both'
            collection='add_on_store'
            query='{segment_3}'
            dynamic_parameters='orderby|sort'
    
        }
    {/if}
    
    
    {partial_search_results}
    
    
    {/exp:low_search:results}
                
            

Cleaning Up Templates

Now that we have our search results code in one template we can go ahead and delete our addons/results template and clear out some of our addons/index template.

  1. Delete the addons/results template.
  2. In our build, we are going to be using the data in the response from our AJAX request and populating the DOM with the data. To do this, we need a place for the data to go.
    We already have a place in the addons/index template where our results live, so we are just going to use that. Go ahead and delete the {exp:low_search:results} tag pair and everything in between.
                
    {layout='layouts/_html-wrapper'}
    {partial_search_functions}
    
    <div class="container my-12 mx-auto px-4 md:px-12 w-10 inline-block align-top">
        <div class="flex flex-wrap -mx-1 lg:-mx-4">
            {!-- this will be populated by AJAX --}
        </div>
    </div>
                
            
  3. We're also going to go and jump ahead here and give our container an ID. This will give us an element on the DOM to target when populating the data.
                
    {layout='layouts/_html-wrapper'}
    {partial_search_functions}
    
    <div class="container my-12 mx-auto px-4 md:px-12 w-10 inline-block align-top">
        <div class="flex flex-wrap -mx-1 lg:-mx-4 id="addon-results">
            {!-- this will be populated by AJAX --}
        </div>
    </div>
                
            
  4. Since we are going to submit our Low Search form on user input, we can also go ahead and remove our submit button from the {partial_search_functions} partial.
  5. Finally, we no longer want our search form to submit to /addons/results since we deleted that template. Instead, we want to go ahead and tell it to submit to our new template ajax/addon-results. Low Search will use that param to create the action property of the form, which we will in turn use for our AJAX requests.
                
    {exp:low_search:form
        result_page="ajax/addon-results"
        form_id="addon_filter"
        query="{segment_3}" 
    }
                
            

  6. Voilà, we now have our templates prepped and ready for some AJAX.

Writing Our JavaScript

Now that we have our templates ready, we can write our JavaScript that will be used to trigger the AJAX requests and handle the responses.

Note that you can either write this JavaScript to a separate .js file (just make sure you include it in your HTML) or you can just place it in your /addons/index template. For our purposes, I'm just including it in our template.

As always, let's start by thinking through what we need our JavaScript to do with some pseudo-code.

    
{!-- when /addons page is first loaded --}
when the search page is first loaded
    request initial results set

{!-- handle search functions --}
if user types a keyword and presses enter/return on keyboard
    submit search results and return data

if user selects a sort order
    submit search results and return data

if user selects a Compatibility option
    submit search results and return data

if user selects a category option
    submit search results and return data

{!-- AJAX request --}
when called, submit AJAX request to passed in URL
    if AJAX is successful, populate response in the element with an ID of "addon-results".
    

Now that we have our plan, let's get to work.

Getting Initial Results via AJAX

  1. Let's set the stage for everything else by getting our initial results.
                
    <script>
        //on load create AJAX request
        // get response from AJAX and populate #addon-results element
    </script>
                
            
  2. Start out by setting some variables.
                
    <script>
        //we're going to need the baseURL for several things here
        function baseUrl() {
            return location.protocol + '//' + location.hostname + (location.port ? ':' + location.port: '');
        }
        const resultsTarget = document.getElementById("addon-results");
        const rp = baseUrl() + '/ajax/addon-results';
    
        //on load create AJAX request
        // get response from AJAX and populate #addon-results element
    </script>
                
            
  3. Now we are able to use the Fetch API's fetch() method to submit our form data via a POST request.
                
    <script>
        //we're going to need the baseURL for several things here
        function baseUrl() {
            return location.protocol + '//' + location.hostname + (location.port ? ':' + location.port: '');
        }
        const resultsTarget = document.getElementById("addon-results");
        const rp = baseUrl() + '/ajax/addon-results';
        
        //on load create AJAX request
        document.addEventListener('DOMContentLoaded', event =>{
        fetch(baseUrl() + "/ajax/addon-results")
            // get response from AJAX and populate #addon-results element
            });
        });
    
    </script>
                
            
  4. Now that we have created the request, we need to handle the response. This whole section is one that's a lot easier with many JavaScript libraries, but we're keeping things vanilla here. So we're going to hopefully get a response, then grab the text from that response which is the HTML from our ajax/addon-results template.
                
    <script>
        //we're going to need the baseURL for several things here
        function baseUrl() {
            return location.protocol + '//' + location.hostname + (location.port ? ':' + location.port: '');
        }
        const resultsTarget = document.getElementById("addon-results");
        const rp = baseUrl() + '/ajax/addon-results'
        
        //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;
                });
        });
    
    </script>
                
            

Great! At this point, you should be able to load your /addons page and get an initial set of results. If all goes well, your /addons page shouldn't look any different in the browser than it did after Part 1.

There's still more work to do though, so let's keep going

Hijacking The Search Form

While our /addons page looks good there are a few issues. Mainly that if you submit the search form, it tries to navigate to the page ajax/addon-results/[encoded query]. Let's fix that by hijacking the form and getting the data via AJAX.

We know that we want the form to submit via AJAX whenever a search function is updated and not reload the page. To do that, we'll need to bind an event listener to each element in our search functions section, and fire an AJAX request each time they are changed. We also need to prevent the form from submitting on its own (which would then redirect the user to a separate page in the browser.

Note: Continue to add this code wherever you were including the JavaScript from above (either in a separate .js file or in the addons/index template like I am ).

  1. So that we don't have to reinvent the wheel, we'll want a function to use which will handle our AJAX requests and responses. We also know that currently results are returned when a form is submitted. This means that we'll need to hijack the Low Search form to not submit as normal, capture the data, and submit the data to the webserver via AJAX.

    Let's see what that looks like.

    
    <script>
        // ... continuing JS from above
    
        function ajaxFormSubmit () {
            //get the form data
            //submit the data via AJAX
            //return the results
        }
    </script>
    
            
  2. If you were going to reuse this for multiple forms on a page, then you may want to allow for a DOM element to be passed into the function allowing it to bind itself to a specified form. For our work, there's only one form we want to target, so we're just going to tell the function what form to target by searching the DOM for an element with the ID of addon_filter.
    
    <script>
        // ... continuing JS from above
        function ajaxFormSubmit () {
            //get the form data
            var searchForm = document.getElementById("addon_filter");
            //submit the data via AJAX
            //return the results
        }
    </script>
    
            
  3. Next we're going to use the FormData interface to create an object containing our form's data.
                
    <script>
        // ... continuing JS from above
        function ajaxFormSubmit () {
            //get the form data
            var searchForm = document.getElementById("addon_filter");
            var formData = new FormData(searchForm);
    
            //submit the data via AJAX
            //return the results
        }
    </script>
                
            
  4. Now we are able to use fetch() again to submit our form data via a POST request.
                
    <script>
        // ... continuing JS from above
        function ajaxFormSubmit () {
            //get the form data
            var searchForm = document.getElementById("addon_filter");
            var formData = new FormData(searchForm);
    
            //submit the data via AJAX
            fetch(searchForm.action, {
                method: 'post',
                body: formData
            })
            //return the results
        }
    </script>
                
            
  5. Just like when we loaded our initial results, we are going to take our response and extract the HTML that is returned from our ajax/addon-results template.
    
    <script>
        // ... continuing JS from above
        function ajaxFormSubmit () {
            //get the form data
            var searchForm = document.getElementById("addon_filter");
            var formData = new FormData(searchForm);
    
            //submit the data via AJAX. Since we updated the results page of our Low Search parameters, fetch() will use that action url to fetch the data.
            fetch(searchForm.action, {
                method: 'post',
                body: formData
            })
            // 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;
            })
        }
    </script>
    
    
  6. Now let's use our ajaxFormSubmit() function to allow the user to submit the search form without being redirected.

    To do that, we're simply going to bind a change event listener to each element.
    • First to our sort option. Put this in the same place you've been putting the other JS.
                  
      <script>
          //fire search form when sorting filters are updated
          document.getElementById("searchResultsSortSelect").addEventListener('change', event => {
              ajaxFormSubmit();
          });
      </script>
                  
              
    • Next we're going to catch any attempts to submit the form via keyboard or other means (think typing a keyword and pressing enter) by binding an event listener to the submit action. We're going to prevent the default action (which is to redirect the user to a results page), and run our ajaxFormSubmit() function.
                          
      <script>
          // catch any submit events on our search form.
          document.getElementById("addon_filter").addEventListener('submit', event =>{
              // prevent the default action
              event.preventDefault();
      
              //submit the form via our function
              ajaxFormSubmit();
          });
      </script>
                          
                      
    • Now to each checkbox. For this we're going to go back to our {partial_search_functions} partial and update our JavaScript to submit the form when checkboxes are clicked.
                      
      <script>
          const filterAccordions = document.querySelectorAll('.filter-accordion');
          filterAccordions.forEach(filterAccordions =>{
              let filterCheckbox = filterAccordions.querySelectorAll('[data-action-uncheck]');
      
              filterCheckbox.forEach(filterBox => {
                  filterBox.addEventListener('change', event => {
                      var showAllFilter = filterBox.getAttribute("data-action-uncheck");
                      document.getElementById(showAllFilter).checked = false;
      
                      //submit the search form now
                      ajaxFormSubmit();
                  })
              })
          });
      </script>
                      
                  
    • Again in our {partial_search_functions} partial we'll update the event triggered when a "Show All" box is checked.
                      
      <script>
          function showAll(cn,cbId){
              if (document.getElementById(cbId).checked){
                  var cbarray = document.getElementsByName(cn);
                  for(var i = 0; i < cbarray.length; i++){
                      if (cbarray[i] != document.getElementById(cbId)){
                          cbarray[i].checked = false;
                      }
                  }
                  //submit the search form now   
                  ajaxFormSubmit();
              }
          }
      </script>
                      
                  

Ensuring Pagination Works With AJAX

At this point, you should be able to see search results populate on your page as you change search inputs. However, if you try to click on the pagination, you'll notice that the user is redirected to the ajax/addon-results template.

If you haven't figured it out, this is because the pagination still thinks it's being rendered at /ajax/addon-results URL. Instead, we've asked JavaScript to reach out to that URL, grab the contents, and insert the HTML on our page.

There are probably several ways to handle this. To keep things simple and just use what we're given, I like to simply bind an event to the pagination links that will again make an AJAX request when a user clicks one of the links.

One last thing before we start writing code. It's important to understand event listeners and loading new content into the DOM. Typically, you would write an event listener and bind it to whatever elements on the page you want to (e.g., our submit event listener that we're binding to the search form). Typically these listeners are bound once the DOM has loaded. However, when working with AJAX, content is being loaded after the DOMContentLoaded event takes place. So, if we want to bind a click event listener to our pagination links, we need to bind that after the pagination is loaded. We also need to bind that event every time that new content is loaded into our DOM (i.e., every time we get a response from our AJAX request).

With that being said, let's get started by writing a function we can call every time we load new content (specifically new pagination links). We'll continue to add this to the JavaScript in our addons/index template.

  1. Let's go ahead and plan this out first.
                
    <script>
        //must be called AFTER ajax loads
        function paginationLinks() {
            //find all pagination links
            // when the links are clicked
                // -> do not redirect the user
                // -> grab the page link url and fetch the contents of that URL using AJAX
                // -> replace the contents of our #addon-results container with the next page of results
        }
    </script>
                
            
  2. Great! We have a plan. Let's write some JavaScript. At this point I think you get the idea of what we're doing, so I'm just going to put this out here in one big chunk.
                       
    <script>
        //paginationLinks
        //adds event listener to pagination
        //must be called AFTER ajax loads
        function paginationLinks() {
            //find all pagination links
            document.querySelectorAll('.pagination__page').forEach(pageLink => {
                
                //bind the click event to each pagination link
                pageLink.addEventListener('click', (event) => {
    
                    //prevent the default action (following the link to another page)
                    event.preventDefault();
    
                    //grab the link from the pagination link
                    var pageUrl = pageLink.href;
    
                    //fetch the contents of the pagination link URL
                    fetch(pageUrl)
                        .then(response => {
                            return response.text();
                        })
                        .then(data => {
                            resultsTarget.innerHTML = data;
    
                            //since new data is loaded, we need to bind this event to new pagination links every time. No, this does not cause a loop.
                            paginationLinks();
                        });
    
    
                })
            });
        }
    </script>
            
        
  3. Almost done. The last thing we need to do is actually call our new paginationLinks() function after we get our AJAX responses. We've already done this above when we load pages. However we still need to do this after our initial search results and after we submit the search form.
    • In our ajaxFormSubmit() function
                    
      <script>
          function ajaxFormSubmit () {
              ...
              .then(response => {
                  return response.text();
              })
              .then(data =>{
                  resultsTarget.innerHTML = data;
                  paginationLinks();
              })
          }
      </script>
                  
              
    • And after our inital results load
                   
      <script>
      document.addEventListener('DOMContentLoaded', event =>{
          ...
              .then(data => {
                  ...
                  paginationLinks();
              });
      });
      </script>
                  
              

Congrats! At this point you should have a fully functioning search page that retrieves results using AJAX.

Next Steps

In Part 3 of this series, we're going to continue updating our search feature by allowing users to select results as their "favorites". This will help users easily find items they search for often.

If that doesn't interest you, then your journey can still continue. Here's a few other items to consider with what we've done in these first two parts:

Good luck, and never hesitate to reach out if you have any questions!

Comments 0

Be the first to comment!