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:
- Prevent reloads of the search/results page on each search
- Refresh the results each time a search input is clicked or updated.
Notes:
- If you're unfamiliar with AJAX, here's a great simple explanation: W3Schools, What Is AJAX?
In This Article
- Moving and Refactoring Templates To Be Used By AJAX
- Writing Our JavaScript
- 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.
- 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
- Refactor our
addons/index
template to handle responses from our AJAX calls
One Results Template
- 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
-
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. -
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
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}
-
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 currentaddons/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' } ...
/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}
-
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}
Link To Current Template Code:
ajax/addon-results
: https://raw.githubusercontent.com/ops-andy/eeu-search-favorite-tutorial/main/08-ajax-results-template.html
Key Concepts
- URL Segments: https://docs.expressionengine.com/...url-segments.html
- ExpressionEngine parsing order: https://docs.expressionengine.com/...#rendering-order
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.
- Delete the
addons/results
template. - 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 theaddons/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>
-
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>
-
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. -
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 templateajax/addon-results
. Low Search will use that param to create theaction
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}" }
addons/index
template: https://github.com/ops-andy/eeu-search-favorite-tutorial/blob/main/8a-addons-index.html{partial_search_functions}
partial: https://raw.githubusercontent.com/ops-andy/eeu-search-favorite-tutorial/main/8a-partial_search_functions.html
Link To Current Template Code:
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
-
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>
-
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>
-
Now we are able to use the Fetch API's
fetch()
method to submit our form data via aPOST
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>
-
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
Link To Current Template Code:
ajax/addon-results
: https://raw.githubusercontent.com/ops-andy/eeu-search-favorite-tutorial/main/09-onload-addons-page.html
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 ).
- 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>
-
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>
-
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>
-
Now we are able to use
fetch()
again to submit our form data via aPOST
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>
- 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>
-
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 achange
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 ourajaxFormSubmit()
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>
- First to our sort option. Put this in the same place you've been putting the other JS.
Link To Current Template Code:
{partial_search_functions}
partial: https://raw.githubusercontent.com/ops-andy/eeu-search-favorite-tutorial/main/10-partial_search_functions.htmladdons/index
template: https://raw.githubusercontent.com/ops-andy/eeu-search-favorite-tutorial/main/11-addons-index.htmlKey Concepts
JS Event Listeners
https://www.w3schools.com/js/js_htmldom_eventlistener.aspfetch()
https://developer.mozilla.org/.../fetchEvent.preventDefault()
https://developer.mozilla.org/en-US/.../preventDefault
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.
-
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>
-
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>
-
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>
- In our
Congrats! At this point you should have a fully functioning search page that retrieves results using AJAX.
Link To Current Template Code:
addons/index
template: https://raw.githubusercontent.com/ops-andy/eeu-search-favorite-tutorial/main/12-addons-idex.htmlNext 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:
- Consider Updating the Pagination to be more SEO friendly. https://u.expressionengine.com/.../seo-and-user-friendly-ajax-pagination
- Try creating an animation or message of some sort that loads in place of your search results while the user waits for the AJAX response to load
- We have several places where we are using
fetch()
. Consider condensing those into one reusable function to make your JS even more DRY
Good luck, and never hesitate to reach out if you have any questions!
Comments 0
Be the first to comment!