Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save bobwol/ac59da6171f2a0e689471cae1b6f41e8 to your computer and use it in GitHub Desktop.
Save bobwol/ac59da6171f2a0e689471cae1b6f41e8 to your computer and use it in GitHub Desktop.
Add Category Filters to Woocommerce Layered Nav

My last project was a small online store that used woocommerce with Wordpress. It wasn't too hard except for a thing: Making the Filters for the shop.

Ok but why?

So the idea of filtering per product category might seem far fetched, since you know, you got category archives. But for certain cases they can be helpful. In the case I was working on they were used for browsing through a long list of “On Sale” tagged products. Since the products there might come from any category on the site it was useful to do it. Using it on Search results might also be useful.

How Woocommerce Layered Nav Works

First of all, apparently this is something no one ever has wanted to do or I was just searching for it all wrong. Probably the latter. Anyway first we have to understand how the woocommerce layered nav filters work. After tracing various functions you will find that the Queries for the shop when a filter is active works this way:

  • layered_nav_init() function in WC_Query class gets triggered when a widget is active. This function sets a global variable $_chosen_attributes that is nothing more than an array of the selected taxonomy terms and the query type (AND, OR), that it gets from the GET variables.
  • When the Main Loop is called WC_Query does its magic. The product_query() function sets a bunch of extra arguments (the meta query) appended.
  • get_products_in_view() gets all the products that will be shown on the current page (for example if you are looking at duh, a category) and furthermore it filters them through intersections on the Post IDs. Now you are getting it, right? Woocommerce queries only get the Post IDs, makes a bunch of queries to get them and then intersects those IDs to get only the ones that comply to all the filters set.
  • layered_nav_query() is the final part of the equation. This function is the one in charge of getting the Post IDs for each of the $_chosen_attributes of the Layered Navigation. It intersects all of those IDs and sets it on WC()->query->layered_nav_post__in so that get_products_in_view() can intersect them.

So now that we understand the inner workings of the Woocommerce filters we can get where we can set to work.

(There’s a lot more going on back on WCQuery, but I just wanted to focus on the principal stuff)

loop_shop_post_in filter

The filter where we can set action is the one that is called just after the Layered Nav inits, just before the layered_nav_query() does its queries to filter Post IDs. This filter is called loop_shop_post_in.

We can set a function here that adds the Product Categories to the $_chosen_attributes global, just like layered_nav_init() does for the product attribute taxonomies. Remember that the taxonomy is set as product_cat in Woocommerce.

And that’s it! We don’t need to do anything else since the layered_nav_query() is intelligent enough to make loops through the filters set.

A new Layered Nav Widget

Sadly we can’t just add the “Product Category” taxonomy to show up in the widget area as there are no filters/actions to hook on. So we need to create our own Widget, luckily enough we can just inherit from the original widget WC_Widget_Layered_Nav and then just replace it on widgets_init.

I can't take too much credit for this part as most of it is mostly just some functions from the original Woocommerce plugin with little variations. My changes are commented. Most prominently the filter won't display if we are currently browsing a Product Category, which makes more sense to me than just not showing the term we are currently on. But if you would rather keep it working the same as other filters just comment that out.

Future stuff

This will be plublished shortly as a plugin, as it just makes way more sense as a standalone thing than something inside a theme. Even more I will add to it the Product Tags option to filter, since adding it (or just any other taxonomy you may add to Products) as a filter option on the Layered Nav is exactly the same.

Delving into woocommerce was sure fun! The callbacks and hooks from Wordpress can make it a little messy to find out all that's happening behind the scenes to make something as complex as a storefront to work.

/*
* All this makes more sense as a plugin than including it on functions.php
* on your theme. This is just a demonstration, but rather use my plugin.
*/
include_once('WC_Widget_Layered_Nav_Categories.php');
define( 'PRODUCTS_CATEGORY_TAXONOMY', 'product_cat' );
add_filter( 'loop_shop_post_in', 'ob_add_categories_filter', 5, 1 );
/**
* ob_add_categories_filter function
*
* Sets the global $chosen_attributes to include the Product Categories
*
* @param array $filtered_posts
*
* @return $filtered_posts
*/
function ob_add_categories_filter( $filtered_posts ) {
global $_chosen_attributes;
$taxonomy = wc_sanitize_taxonomy_name( PRODUCTS_CATEGORY_TAXONOMY );
$name = 'filter_' . PRODUCTS_CATEGORY_TAXONOMY;
$query_type_name = 'query_type_' . PRODUCTS_CATEGORY_TAXONOMY;
if ( ! empty( $_GET[ $name ] ) && taxonomy_exists( $taxonomy ) ) {
$_chosen_attributes[ $taxonomy ]['terms'] = explode( ',', $_GET[ $name ] );
if ( empty( $_GET[ $query_type_name ] ) || ! in_array( strtolower( $_GET[ $query_type_name ] ), array(
'and',
'or'
) )
) {
$_chosen_attributes[ $taxonomy ]['query_type'] = apply_filters( 'woocommerce_layered_nav_default_query_type', 'and' );
} else {
$_chosen_attributes[ $taxonomy ]['query_type'] = strtolower( $_GET[ $query_type_name ] );
}
}
return $filtered_posts;
}
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Layered Navigation Widget extended to include Categories
*
* @author Oscar Bazaldua
* @category Widgets
* @package WooCommerceLayeredNavCategory/Widgets
* @version 1.0
* @extends WC_Widget_Layered_Nav
*/
class WC_Widget_Layered_Nav_Categories extends WC_Widget_Layered_Nav {
/**
* Init settings adding the product category taxonomy
*
* @return void
*/
public function init_settings() {
$attribute_array = array();
$category_taxonomy = get_taxonomies( array( 'name' => PRODUCTS_CATEGORY_TAXONOMY ), 'objects' );
$attribute_taxonomies = wc_get_attribute_taxonomies();
if ( $category_taxonomy ) {
$category_taxonomy = array_pop( $category_taxonomy );
$attribute_array[ PRODUCTS_CATEGORY_TAXONOMY ] = $category_taxonomy->label;
}
if ( $attribute_taxonomies ) {
foreach ( $attribute_taxonomies as $tax ) {
$attribute_key = wc_attribute_taxonomy_name( $tax->attribute_name );
if ( taxonomy_exists( $attribute_key ) ) {
$attribute_array[ $attribute_key ] = $tax->attribute_label;
}
}
}
$this->settings = array(
'title' => array(
'type' => 'text',
'std' => __( 'Filter by', 'woocommerce' ),
'label' => __( 'Title', 'woocommerce' )
),
'attribute' => array(
'type' => 'select',
'std' => '',
'label' => __( 'Attribute', 'woocommerce' ),
'options' => $attribute_array
),
'display_type' => array(
'type' => 'select',
'std' => 'list',
'label' => __( 'Display type', 'woocommerce' ),
'options' => array(
'list' => __( 'List', 'woocommerce' ),
'dropdown' => __( 'Dropdown', 'woocommerce' )
)
),
'query_type' => array(
'type' => 'select',
'std' => 'and',
'label' => __( 'Query type', 'woocommerce' ),
'options' => array(
'and' => __( 'AND', 'woocommerce' ),
'or' => __( 'OR', 'woocommerce' )
)
),
);
}
/**
* Widget Display Function.
*
* Added ability to hide the widget if its filter attribute is a category
* and the current page is category archive
* Changed the way it constructs the filters so that it uses the value
* instead of recreating the attribute id.
*
* @param array $args
* @param array $instance
*
* @return void
*/
public function widget( $args, $instance ) {
global $_chosen_attributes;
if ( ! is_post_type_archive( 'product' ) && ! is_tax( get_object_taxonomies( 'product' ) ) ) {
return;
}
$current_term = is_tax() ? get_queried_object()->term_id : '';
$current_tax = is_tax() ? get_queried_object()->taxonomy : '';
$taxonomy = isset( $instance['attribute'] ) ? $instance['attribute'] : $this->settings['attribute']['std']; // Changed this to use the attribute as is, since we set it now as the taxonomy name (not as label)
$query_type = isset( $instance['query_type'] ) ? $instance['query_type'] : $this->settings['query_type']['std'];
$display_type = isset( $instance['display_type'] ) ? $instance['display_type'] : $this->settings['display_type']['std'];
// Skip Display if we are browsing a product category
if ( is_product_category() && $taxonomy == PRODUCTS_CATEGORY_TAXONOMY) {
return;
}
if ( ! taxonomy_exists( $taxonomy ) ) {
return;
}
$get_terms_args = array( 'hide_empty' => '1' );
$orderby = wc_attribute_orderby( $taxonomy );
switch ( $orderby ) {
case 'name' :
$get_terms_args['orderby'] = 'name';
$get_terms_args['menu_order'] = false;
break;
case 'id' :
$get_terms_args['orderby'] = 'id';
$get_terms_args['order'] = 'ASC';
$get_terms_args['menu_order'] = false;
break;
case 'menu_order' :
$get_terms_args['menu_order'] = 'ASC';
break;
}
$terms = get_terms( $taxonomy, $get_terms_args );
if ( 0 < count( $terms ) ) {
ob_start();
$found = false;
$this->widget_start( $args, $instance );
if ( ! is_tax() && is_array( $_chosen_attributes ) && array_key_exists( $taxonomy, $_chosen_attributes ) ) {
$found = true;
}
if ( 'dropdown' == $display_type ) {
if ( $current_tax && $taxonomy == $current_tax ) {
$found = false;
} else {
$taxonomy_filter = str_replace( 'pa_', '', $taxonomy );
$found = false;
echo '<select class="dropdown_layered_nav_' . $taxonomy_filter . '">';
echo '<option value="">' . sprintf( __( 'Any %s', 'woocommerce' ), wc_attribute_label( $taxonomy ) ) . '</option>';
foreach ( $terms as $term ) {
if ( $term->term_id == $current_term ) {
continue;
}
$transient_name = 'wc_ln_count_' . md5( sanitize_key( $taxonomy ) . sanitize_key( $term->term_taxonomy_id ) );
if ( false === ( $_products_in_term = get_transient( $transient_name ) ) ) {
$_products_in_term = get_objects_in_term( $term->term_id, $taxonomy );
set_transient( $transient_name, $_products_in_term, YEAR_IN_SECONDS );
}
$option_is_set = ( isset( $_chosen_attributes[ $taxonomy ] ) && in_array( $term->term_id, $_chosen_attributes[ $taxonomy ]['terms'] ) );
if ( 'and' == $query_type ) {
$count = sizeof( array_intersect( $_products_in_term, WC()->query->filtered_product_ids ) );
if ( 0 < $count ) {
$found = true;
}
if ( 0 == $count && ! $option_is_set ) {
continue;
}
} else {
$count = sizeof( array_intersect( $_products_in_term, WC()->query->unfiltered_product_ids ) );
if ( 0 < $count ) {
$found = true;
}
}
echo '<option value="' . esc_attr( $term->term_id ) . '" ' . selected( isset( $_GET[ 'filter_' . $taxonomy_filter ] ) ? $_GET[ 'filter_' . $taxonomy_filter ] : '', $term->term_id, false ) . '>' . esc_html( $term->name ) . '</option>';
}
echo '</select>';
wc_enqueue_js( "
jQuery('.dropdown_layered_nav_$taxonomy_filter').change(function(){
location.href = '" . esc_url_raw( preg_replace( '%\/page/[0-9]+%', '', add_query_arg( 'filtering', '1', remove_query_arg( array(
'page',
'filter_' . $taxonomy_filter
) ) ) ) ) . "&filter_$taxonomy_filter=' + jQuery(this).val();
});
" );
}
} else {
echo '<ul>';
foreach ( $terms as $term ) {
$transient_name = 'wc_ln_count_' . md5( sanitize_key( $taxonomy ) . sanitize_key( $term->term_taxonomy_id ) );
if ( false === ( $_products_in_term = get_transient( $transient_name ) ) ) {
$_products_in_term = get_objects_in_term( $term->term_id, $taxonomy );
set_transient( $transient_name, $_products_in_term );
}
$option_is_set = ( isset( $_chosen_attributes[ $taxonomy ] ) && in_array( $term->term_id, $_chosen_attributes[ $taxonomy ]['terms'] ) );
if ( $current_term == $term->term_id ) {
continue;
}
if ( 'and' == $query_type ) {
$count = sizeof( array_intersect( $_products_in_term, WC()->query->filtered_product_ids ) );
if ( 0 < $count && $current_term !== $term->term_id ) {
$found = true;
}
if ( 0 == $count && ! $option_is_set ) {
continue;
}
} else {
$count = sizeof( array_intersect( $_products_in_term, WC()->query->unfiltered_product_ids ) );
if ( 0 < $count ) {
$found = true;
}
}
$arg = 'filter_' . sanitize_title( $instance['attribute'] );
$current_filter = ( isset( $_GET[ $arg ] ) ) ? explode( ',', $_GET[ $arg ] ) : array();
if ( ! is_array( $current_filter ) ) {
$current_filter = array();
}
$current_filter = array_map( 'esc_attr', $current_filter );
if ( ! in_array( $term->term_id, $current_filter ) ) {
$current_filter[] = $term->term_id;
}
if ( defined( 'SHOP_IS_ON_FRONT' ) ) {
$link = home_url();
} elseif ( is_post_type_archive( 'product' ) || is_page( wc_get_page_id( 'shop' ) ) ) {
$link = get_post_type_archive_link( 'product' );
} else {
$link = get_term_link( get_query_var( 'term' ), get_query_var( 'taxonomy' ) );
}
if ( $_chosen_attributes ) {
foreach ( $_chosen_attributes as $name => $data ) {
if ( $name !== $taxonomy ) {
while ( in_array( $current_term, $data['terms'] ) ) {
$key = array_search( $current_term, $data );
unset( $data['terms'][ $key ] );
}
$filter_name = sanitize_title( str_replace( 'pa_', '', $name ) );
if ( ! empty( $data['terms'] ) ) {
$link = add_query_arg( 'filter_' . $filter_name, implode( ',', $data['terms'] ), $link );
}
if ( 'or' == $data['query_type'] ) {
$link = add_query_arg( 'query_type_' . $filter_name, 'or', $link );
}
}
}
}
if ( isset( $_GET['min_price'] ) ) {
$link = add_query_arg( 'min_price', $_GET['min_price'], $link );
}
if ( isset( $_GET['max_price'] ) ) {
$link = add_query_arg( 'max_price', $_GET['max_price'], $link );
}
if ( isset( $_GET['orderby'] ) ) {
$link = add_query_arg( 'orderby', $_GET['orderby'], $link );
}
if ( isset( $_chosen_attributes[ $taxonomy ] ) && is_array( $_chosen_attributes[ $taxonomy ]['terms'] ) &&
in_array( $term->term_id, $_chosen_attributes[ $taxonomy ]['terms'] ) ) {
$class = 'class="chosen"';
if ( sizeof( $current_filter ) > 1 ) {
$current_filter_without_this = array_diff( $current_filter, array( $term->term_id ) );
$link = add_query_arg( $arg, implode( ',', $current_filter_without_this ), $link );
}
} else {
$class = '';
$link = add_query_arg( $arg, implode( ',', $current_filter ), $link );
}
if ( get_search_query() ) {
$link = add_query_arg( 's', get_search_query(), $link );
}
if ( isset( $_GET['post_type'] ) ) {
$link = add_query_arg( 'post_type', $_GET['post_type'], $link );
}
if ( $query_type == 'or' &&
! ( sizeof( $current_filter ) == 1 &&
isset( $_chosen_attributes[ $taxonomy ]['terms'] ) &&
is_array( $_chosen_attributes[ $taxonomy ]['terms'] ) &&
in_array( $term->term_id, $_chosen_attributes[ $taxonomy ]['terms'] ) ) ) {
$link = add_query_arg( 'query_type_' . sanitize_title( $instance['attribute'] ), 'or', $link );
}
echo '<li ' . $class . '>';
echo ( $count > 0 || $option_is_set ) ? '<a href="' . esc_url( apply_filters( 'woocommerce_layered_nav_link', $link ) ) . '">' : '<span>';
echo $term->name;
echo ( $count > 0 || $option_is_set ) ? '</a>' : '</span>';
echo ' <small class="count">' . $count . '</small></li>';
}
echo '</ul>';
}
$this->widget_end( $args );
if ( ! $found ) {
ob_end_clean();
} else {
echo ob_get_clean();
}
}
}
}
add_action( 'widgets_init', 'replace_layered_nav_widget', 11 );
function replace_layered_nav_widget() {
unregister_widget( 'WC_Widget_Layered_Nav' );
register_widget( 'WC_Widget_Layered_Nav_Categories' );
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment