WordPress Search Ajax + Isotope + InfiniteScroll

I was able to figure it out…well as long as jQuery and AJAX are alive and utilized by WordPress (I know it may be best to use WordPress REST API, but …this works)!

GET STARTED

I did have to edit searchform.php, functions.php, search.php, and custom-search.js. Please make sure taxonomies and post types are queryable (public) and query vars are set to true. If you used CPT UI you can edit them here:

http://domain.com/wp-admin/admin.php?page=cptui_manage_taxonomies&action=edit

Set the ‘Public Queryable’ option to ‘true’ and ‘Query Var’ to ‘true’.

SEARCHFORM.PHP

I had to turn off auto-complete on the search and ajax filter form in case of users using the back button since the InfiniteScroll option for history by default is set to true.

<form role="search" method="get" class="search-form" action="<?php echo esc_url( home_url( "https://wordpress.stackexchange.com/" ) ); ?>" autocomplete="off">
<select id="drpdwn_search">
    <option value="any" selected>All</option>
    <option value="opone">One</option>
    <option value="optwo">Two</option>
    <option value="opthree">Three</option>
</select>

<input type="search" class="search-field form-control" name="s" placeholder="Search" value="<?php echo esc_attr( get_search_query() ); ?>" title="<?php _ex( 'Search for:', 'label', 'wp-bootstrap-starter' ); ?>">
    <input type="hidden" name="post_type" value="any" />
        <input type="submit" class="search-submit btn btn-default" value="<?php echo esc_attr_x( 'Search', 'submit button', 'wp-bootstrap-starter' ); ?>">
</form>

As you can see if a user does not select a post type it defaults to any’ post type for the value.

<input type="hidden" name="post_type" value="any" />

SEARCH.PHP

The ajax filter form fields should all be required through HTML.

Ajax Filter – Form Tag

I added the autocomplete attribute on the form tag and set it to off.

Ajax Filter Form – Taxonomies

FYI: My taxonomies are custom post type categories created with CPT UI – as you can see throughout the whole thing I did not use WP Core Categories.

In the ajax filter form, I edited the taxonomies filter to get all terms when the post type is set to any in the else statement.

Ajax Filter Form – Search Field

I then set the search-field input type to hidden so they can only filter the search term from the form in searchform.php. I plan on displaying the search form in the header (header.php) so it can be everywhere so… the search field in the ajax filter form can remain hidden unless you want to filter with it on the search results page).

Default Search Results Query (Not in AJAX Callback Duh..)

I use the default search query loop ($wp_query) for non-ajax-filtered results. We do not need to use a custom search query. This link explains the mistake that I made. The loop in the AJAX callback function uses query_posts() although many articles state not to use it. One reason is that it breaks paging, but it is okay since I will use a hard-coded URL path for the AJAX InfiniteScroll instance.

Ajax Filter Search Results Query – Container DIV

I wanted the ajax call to have its own container, so I added a div with only the same bootstrap classes as the default search query container, but with the id (#ajax_container).

<section class="row">
    <div id="secondary" class="content-area col-sm-12 col-md-2">
<?php
$actual_link = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]";
$param = filter_input(INPUT_GET, 'post_type', FILTER_SANITIZE_URL);
?>
<form action="<?php echo site_url() ?>/wp-admin/admin-ajax.php" method="POST" id="filter" autocomplete="off">
<input type="hidden" class="search-field form-control" name="s" placeholder="Search" value="<?php echo esc_attr(get_search_query()); ?>" title="<?php _ex('Search for:', 'label', 'wp-bootstrap-starter'); ?>">
<input type="hidden" class="form-control" name="post_type" value="<?php echo $param; ?>" />
<?php
if ('font' == $param) {
    if ($terms = get_terms(array('taxonomy' => 'font_cat', 'hide_empty' => false, 'orderby' => 'name'))):
        // if categories exist, display the dropdown
        echo '<select name="categoryfilter" class="form-select" aria-label="One categories"><option value="">Select category...</option>';
        foreach ($terms as $term):
            echo '<option value="' . $term->term_id . '">' . $term->name . '</option>'; // ID of the category as an option value
            
        endforeach;
        echo '</select>';
    endif;
} else if ('snipp' == $param) {
    if ($terms = get_terms(array('taxonomy' => 'snipp_cat', 'hide_empty' => false, 'orderby' => 'name'))):
        // if categories exist, display the dropdown
        echo '<select name="categoryfilter" class="form-select" aria-label="Two categories"><option value="">Select category...</option>';
        foreach ($terms as $term):
            echo '<option value="' . $term->term_id . '">' . $term->name . '</option>'; // ID of the category as an option value
            
        endforeach;
        echo '</select>';
    endif;
} else if ('post' == $param) {
    if ($terms = get_terms(array('taxonomy' => 'blog_cat', 'hide_empty' => false, 'orderby' => 'name'))):
        // if categories exist, display the dropdown
        echo '<select name="categoryfilter" class="form-select" aria-label="Three categories"><option value="">Select category...</option>';
        foreach ($terms as $term):
            echo '<option value="' . $term->term_id . '">' . $term->name . '</option>'; // ID of the category as an option value
            
        endforeach;
        echo '</select>';
    endif;
} else {
    $term_args = array('taxonomy' => array('font_cat', 'snipp_cat', 'blog_cat'), 'hide_empty' => false, 'fields' => 'all', 'count' => true,);
    $term_query = new WP_Term_Query($term_args);
    $term_taxs = $term_args["taxonomy"];
    echo '<select name="categoryfilter" class="form-select" aria-label="All categories" required><option value="">Select category...</option>';
    foreach ($term_taxs as $term_tax):
        if ($term_tax === 'font_cat'):
            echo '<option value="" disabled>Font Categories</option>';
            foreach ($term_query->terms as $term):
                if ($term->taxonomy == 'font_cat'):
                    echo '<option value="' . $term->term_id . '">' . $term->name . '</option>';
                endif;
            endforeach;
        endif;
        if ($term_tax === 'snipp_cat'):
            echo '<option value="" disabled>Snippet Categories</option>';
            foreach ($term_query->terms as $term):
                if ($term->taxonomy == 'snipp_cat'):
                    echo '<option value="' . $term->term_id . '">' . $term->name . '</option>';
                endif;
            endforeach;
        endif;
        if ($term_tax === 'blog_cat'):
            echo '<option value="" disabled>Blog Categories</option>';
            foreach ($term_query->terms as $term):
                if ($term->taxonomy == 'blog_cat'):
                    echo '<option value="' . $term->term_id . '">' . $term->name . '</option>';
                endif;
            endforeach;
        endif;
    endforeach;
    echo '</select>';
}
?>
<div class="form-check form-check-inline">
    <input class="form-check-input" type="radio" id="asc" name="date" value="asc" />
    <label class="form-check-label" for="asc">Date: Ascending</label>
</div>
<div class="form-check form-check-inline">
    <input class="form-check-input" type="radio" id="desc" name="date" value="desc" selected="selected" required />
    <label class="form-check-label" for="dsc">Date: Descending</label>
</div>
<button class="btn btn-primary btn-filter btn-lg">Apply filter</button>
    <input type="hidden" name="action" value="myfilter">
</form>
</div>


        <div class="scroll-content col-sm-12 col-md-10">
        
<?php
global $wp_post_types, $wp_query;
$wp_post_types['page']->exclude_from_search = true;
if (have_posts()):
    while (have_posts()):
        the_post();
        get_template_part('template-parts/content', 'archive');
    endwhile;
    get_template_part('template-parts/pagination', 'notabs');
else:
    get_template_part('template-parts/content', 'none');
endif;
?>
        
        </div><!-- .scroll-content -->
<div id="ajax_container" class="col-sm-12 col-md-10"></div>
</section> <!-- row -->

FUNCTIONS.PHP

AJAX Callback Function – Protocol

Since HTTP and HTTPS are flexible for my domain, I had to specify the protocol for the ajax URL. Of course, HTTPS will only be used in production so this can be removed if HTTPS is forced.

AJAX Callback Function – Query Loop

As stated, since I decided to modify the main query for the search – I used query_posts() and wp_reset_query() after pagination.

add_action('wp_ajax_myfilter', 'search_filter_function'); 
add_action('wp_ajax_nopriv_myfilter', 'search_filter_function');

function search_filter_function(){
    global $wp_post_types, $wp_query;
    $wp_post_types['page']->exclude_from_search = true; 
    $paged = ( get_query_var( 'paged' ) ) ? get_query_var( 'paged' ) : 1;
    $protocol = isset( $_SERVER['HTTPS'] ) ? 'https://' : 'http://';
    $args = array(
        'ajaxurl' => admin_url( 'admin-ajax.php', $protocol ),
        'query'   => $wp_query->query,
        's' => $_POST['s'],
        'post_type' => $_POST['post_type'],
        'post_status' => 'publish',
        'posts_per_page' => 6,
        'orderby' => 'date',  
        'order' => $_POST['date'], // ASC or DESC
        'paged' => $paged
    );
    
    
    // for taxonomies 
    if( isset( $_POST['categoryfilter'] ) ) {
        $args['tax_query'] = array(
            'relation' => 'OR',
            array(
                'taxonomy' => 'font_cat',
                'field'    => 'id',
                'terms'    =>  $_POST['categoryfilter'],
            ),
            array(
                'taxonomy' => 'snipp_cat',
                'field'    => 'id',
                'terms'    =>  $_POST['categoryfilter'],
            ),
            array(
                'taxonomy' => 'blog_cat',
                'field'    => 'id',
                'terms'    => $_POST['categoryfilter'],
            ),
        );
    }

    query_posts( $args );

    if ( have_posts() ) : while ( have_posts() ) : the_post();

            get_template_part( 'template-parts/content', 'search' );

        endwhile; 

            get_template_part( 'template-parts/pagination', 'notabs' );
            wp_reset_query();

        else :

            get_template_part( 'template-parts/content', 'none' ); 
            
        endif;

    
    die();
}

FUNCTIONS.PHP (Continued)

Next Link for Pagination

I needed to add a class to the next paging link so I added a filter to the next_post_link() function for the non-ajax InfiniteScroll instance.

FYI: This is important for the non-ajax InfiniteScroll instance path option since I do not want a hard-coded URL.

add_filter('next_posts_link_attributes', 'next_link_class_attribute');
function next_link_class_attribute() {
  return 'class="next-post-link"';
}

In order to avoid an empty search field, I added this function to send the user to the 404 page if no search term is entered.

//if search is empty to display 404
add_action( 'pre_get_posts', function ( $q )
{
    if($q->is_search() // Only target the search page
    ) {
        // Get the search terms
        $search_terms = $q->get( 's' );

        // Set a 404 if s is empty
        if ( !$search_terms ) {
            add_action( 'wp', function () use ( $q )
            {
                $q->set_404();
                status_header(404);
                nocache_headers();
            });
        }
    }
});

I also set 404 if there are only one or more invalid query vars. I do not recommend this function if you have not registered custom query vars.
https://gist.github.com/carlodaniele/1ca4110fa06902123349a0651d454057

//set 404 only if one or more invalid query vars (invalid taxonomy names but not only) are in the query
add_action( 'template_redirect', 'my_page_template_redirect' );
function my_page_template_redirect() {
  global $wp_query, $wp;
  // this get an array of the query vars in the url
  parse_str( parse_url ( add_query_arg( array() ), PHP_URL_QUERY ), $qv);
  if ( ! empty( $qv ) ) { // if there are some query vars in the url
    $queried = array_keys( $qv ); // get the query vars name
    $valid = $wp->public_query_vars; // this are the valid query vars accepted by WP
    // intersect array to retrieve the queried vars tha are included in the valid vars
    $good = array_intersect($valid, $queried); 
    // if there are no good query vars or if there are at least one not valid var
    if ( empty($good) || count($good) !== count($queried)  ) {
     $wp_query->set_404();
     status_header(404);
     nocache_headers();
    }
  }
}

UPGRADE InfiniteScroll to v4
I did upgrade to InfiniteScroll v4 because loadNextPage returns a Promise, so basically I can use then() in case I want to do something after each load of posts. Also, because of its backward compatibility with v3.

CUSTOM-SEARCH.JS

AJAX Filter – Submit without Reload

I had to bind the filter button to a click event so we can apply filters without having to reload the entire page.

AJAX Filter – InfiniteScroll and Isotope Sessions

Once a user clicks submit on the filter, I use the InfiniteScroll and Isotope destroy method on both containers ($scroll_container and $ajax_container) in case isotope or infinite scroll already exists (destroy session on success to first create new).

FYI: .data('inifiniteScroll') and InfiniteScroll.data( element ) does not work in v3 and v4 so I cannot check if InifinteScroll is initialized before using destroy method so error will show in console.

I then empty the AJAX container (#ajax_container.empty()) as well to make sure new results are displayed every time the user filters with the ajax filter form.

AJAX Filter – Isotope
I was able to get Isotope to work on the results by turning the response (data) to a jQuery object before inserting the data with Isotope.

let $data = $(data); // store data in jQuery object
$ajax_container.append($data);
$ajax_container.isotope('insert', $data ); 

AJAX Filter – InfiniteScroll
I did find a solution to fix InfiniteScroll by setting the path correctly – found through console logging the data to get the query strings for pagination (console.log(filter.serialize())).

As stated prior, now only the AJAX InfiniteScroll instance uses a hard-coded path option. I tried creating the path with just public query vars but had issues with paged results after setting pretty links (customizing the slug – rewrites) for post types and taxonomies.

This was done with the CPT UI plugin:
http://domain.com/wp-admin/admin.php?page=cptui_manage_taxonomies&action=edit

I set ‘Rewrite’ to ‘true’, added ‘Custom Rewrite Slug’ text, and set ‘Rewrite With Front’ to ‘true’.

As stated prior, I still would have to manually build the path URL for the AJAX InfiniteScroll instance.

The good news is paging is possible with rewrites based on my setup.

http://domain.com/[taxonomy rewrite slug]/[taxonomy term]/page/{{#}}/?s=[search term]&orderby=date&order=[asc or desc]

As you can see the path option for the AJAX InfiniteScroll Instance is now set properly.

jQuery(window).on('load', function () {

    let $scroll_container = jQuery('.scroll-content');
     
    $scroll_container.isotope({
        layoutMode: 'fitRows',
        itemSelector: '.scroll-post'
    }); //isotope

    let currentLocation = window.location.href;
    const ptParams = new Proxy(new URLSearchParams(window.location.search), {get: (searchParams, prop) => searchParams.get(prop),});

    let post_type_value = ptParams.post_type;
    let iso = $scroll_container.data('isotope');
    
    if(iso) {
    $scroll_container.infiniteScroll({
        //path: 'page/{{#}}/?s=" + searchParam.search_term + "&post_type=" + post_type_value,
        path: ".next-post-link',
        append: '.scroll-post',
        button: '.btn-scroll',
        outlayer: iso,
        loadOnScroll: false,
        scrollThreshold: 300,
        status: '.page-load-status',        
        hideNav: '.pagination'
    }); //infinite scroll
    
    jQuery('.btn-scroll').on('click', function () {                             
        $scroll_container.on('load.infiniteScroll', function (event) {
            $scroll_container.isotope('layout');
            jQuery('.page-load-status').detach().appendTo(jQuery('.scroll-content'));
        }); //on load function  
    }); //on click function 
} //iso check
    
    /*
     * Filter
     */
    let $ajax_container = jQuery('#ajax_container'); 
    let s_term = jQuery('.search-field').val();
    let s_cat_text = jQuery(".form-select :selected").text(); //not needed for query string
    let s_cat_val = jQuery(".form-select :selected").val(); 
    let s_order = jQuery("input[type="radio"][name="date"]:checked").val();
    let s_post_type = jQuery("input[type="hidden"][name="post_type"]").val();
    
    
     //ajax call
    jQuery('#filter').submit(function(e) { 
        //jQuery('.btn-filter').on('click', function () {
        e.preventDefault();
        let filter = jQuery('#filter');
        jQuery.ajax({
            url: filter.attr('action'),
            data: filter.serialize(), // form data
            type : filter.attr('method'),
            beforeSend : function(xhr) {
                filter.find('.btn-filter').text('Filtering...');
                console.log(filter.serialize());
            },
            success : function( data ) {
                filter.find('.btn-filter').text('Apply filter');
                
                let iso = $scroll_container.data('isotope');
                let inf = $scroll_container.data('infniteScroll');
                
                //remove initial results infinite scroll and isotope for ajax call
                if(inf) {
                    $scroll_container.infiniteScroll('destroy');
                }
                
                if(iso) {
                    $scroll_container.isotope('destroy');
                }
                
                $scroll_container.remove(); //completely remove initial results


                let iso_ajax = $ajax_container.data('isotope');
                //let inf_ajax = $ajax_container.data('infniteScroll'); //does not work, reported to developer
                //InfiniteScroll.data( element ); Infinite Scroll instance via its element does not work either
                
                //if ajax infinite scroll and isotope exist already remove so reset     
                    $ajax_container.infiniteScroll('destroy');  

                if(iso_ajax) {
                    $ajax_container.isotope('destroy');
                    $ajax_container.empty();
                }
            
                //create "new" isotope instance
                $ajax_container.isotope({
                    layoutMode: 'fitRows',
                    itemSelector: '.scroll-post'
                }); //isotope

                let $data = $(data); // store data in jQuery object
                $ajax_container.append($data);
                $ajax_container.isotope('insert', $data );  

                let isoajax = $ajax_container.data('isotope');          
                
                //create "new" infinite scroll instance
                $ajax_container.infiniteScroll({
                    path: '/page/{{#}}/?s=" + s_term + "&post_type=" + s_post_type + "&categoryfilter=" + s_cat_val + "&date=" + s_order,
                    append: ".scroll-post',
                    button: '.btn-scroll',
                    outlayer: isoajax, 
                    loadOnScroll: false,
                    scrollThreshold: 300,
                    checkLastPage: true,
                    history: false,
                    status: '.page-load-status',
                    hideNav: '.pagination',
                    debug: true
                }); //infinitescroll 

                    jQuery('.btn-scroll').on('click', function () {
                            console.log('click ajax inside');
                        $ajax_container.on('load.infiniteScroll', function (event) {
                            console.log('infinite ajax inside');
                        $ajax_container.isotope('layout');
                            jQuery('.page-load-status').detach().appendTo(jQuery('#ajax_container'));
                        }); //on load function
                    }); // on click function 
                // reset filter 
                //filter[0].reset();             
            } // success
        }); //ajax call
        //return false; //e.preventDefault is used
    }); //submit function
    
    //Bind click event listener to the submit button
    jQuery(document).on('click', 'button[type="submit"]', function() {
      jQuery(this).parents('form').submit();
    }); 
}); // window on load

Please excuse my indentation, thats it!

If you can optimize this code to make it better or come up with a it will be greatly appreciated.