HubDB Filter and sort as a module

I am fairly new to Hubl.
NOTE: If this module exists, please share links. It seems like a function that other people would want.
We are making a native plant library, and need a module that filters based on selected columns for our HubDB.
(https://content.gardenforwildlife.com/learn/plant-directory)
The list of plants is a module. and we would love a separate module for filter and sort. This is something I have seen in Blogs, would expect there to be something similar for Hubdb.

I have looked into using Isotope, but cannot seem to get it to work correctly.
I have also tried using hubl to loop/generate the filter options, but since some of the columns are boolean, and some are text, and some are multiselect, it outputs a garbled mess.
I’m at a loss, and have been going in circles for several weeks. Any assistance is appreciated!
Thank you!

Hi @GFW-MillerM this is definitely possible, I’ve created many directories with filters for clients using HubDB and would be happy to refer you to a developer if you need hands-on help.

This is something would would prefer to develop inhouse. Do you have any resources you could direct me to that would help?

Hi @GFW-MillerM (sorry about my previously deleted reply, I shared the wrong link on the wrong reply).

Here’s a video that walks through how to add isotope filters to a custom module that should help you get started.

The video is helpful, unfortunately, I’m having issues applying this onto the hubd output.
one of the elements I’m trying to build is a filter loop that will output all the options of selected columns.

##### NOT THE ACTUAL CODE, JUST A SKETCH OF WHAT I'm GOING FOR ######{% for item in module.column_name %} <h3>{{ item.column_name.label }}</h3> <ul> {% for listitems in {{ item.column_name }}(same as the one entered in the module) %} <li>{{ listitme }}</li> {# no repeats #} {% endfor %} </ul>{% endfor %}

is this too ambitious? the end goal is the use the {{ listitem }}

as a filter tag as well. but that’s another challenge.

the desired HTML output:

<h3>Light Requirements</h3> <ul> <li>Full Sun</li> <li>Part Shade</li> <li>Full Shade</li> </ul><h3>Soil Preferences</h3><ul><li>Clay</li><li>Sandy</li></ul>

@GFW-MillerM While I have some experience coding with HubL and HubDB, I’m not a developer and can’t inspect your code.

Perhaps @Jaycee_Lewis can provide further guidance regarding your question about how you want to structure the code for the filters using HubDB.

Here’s what you’ll need:

  • HubL
    • List of filter properties - this can be a plain text repeater in your module to make it more flexible
    • List of table rows - store the desired table ID in the module for added flexibility
  • HTML
    • Filter options
    • Container w/ item cards
  • JS
    • jQuery library
    • Isotope library
    • Function to handle filter selections

HubL & HTML

{% set this_table = module.hubdb_table %}
{% set all_items = hubdb_table_rows(this_table) %}
{% set filter_columns = ["column_name_1", "column_name_2"] %}

{# MACROS 
 create code-friendly slugs for values
 render HTML for filters
#}

{% macro slugify(string) %}
 {{ string|string|regex_replace("[^a-zA-Z0-9]", "-")|replace(" ","-")|replace(",","-")|replace("$","-")|replace(":","-")|replace("&","and")|lower|trim }}
{% endmacro %}

{% macro render_filter(value, option) %}
<li class="data-filter" tabindex="0">
 <label for="{{ value|trim }}">
 <input type="checkbox" name="{{ value|trim }}" data-filter=".{{ value|trim }}" />
 <span>{{ option.name or option.label or option }}</span>
 </label>
</li>
{% endmacro %}

{% macro render_filter_group(name) %}
 <fieldset class="listing-filters__group {{ "single-select" if name == "year" }}" data-group="{{ slugify(name)|trim }}" aria-expanded="true">
 {% if name %}<label><h4>{{ name }}</h4><span>{{ icons("arrow") }}</span></label>{% endif %}
 <ul aria-hidden="false">
 {{ caller() }}
 </ul>
 </fieldset>
{% endmacro %}

{# Create your filters #}
{% for filter_column in filter_columns %}
 <div class="listing__filter-groups">
 {% call render_filter_group("") %}
 {% set these_options = [] %}
 {% for item in all_items %}
 {% set this_value = item[filter_column] or item.columns[filter_column] %}
 {% set is_array = true if this_value is iterable else false %}
 {% if is_array %}
 {% for val in this_value %}
 {% set append_this = val.label or val.name %}
 {% do these_options.append(append_this) %}
 {% endfor %}
 {% else %}
 {% set append_this = this_value.label or this_value.name %}
 {% do these_options.append(append_this) %}
 {% endif %}
 {% endfor %}
 {% for option in these_options %}
 {% set slug = slugify(option) %}
 {{ render_filter(slug, option) }}
 {% endfor %}
 {% endcall %}
 </div>
{% endfor %}

{# Create the isotope container #}
<div class="isotope-container">
{% for item in all_items %}
 {% set classes = [] %}
 {% if filter_columns %}
 {% for filter_column in filter_columns %}
 {% set prop_name = slugify(filter_column)|trim %}
 {% set this_prop_value = item.columns[prop_name] or item[prop_name] %}
 {% if this_prop_value|length > 0 %} 
 {% for this_item in this_prop_value %}
 {% set this_item_value = this_item[prop_name] or this_item.columns[prop_name] %}
 {% set append_this = slugify(this_item_value)|trim %}
 {% do classes.append(append_this) %}
 {% endfor %}
 {% else %}
 {% set this_item_value = [this_prop_value]|first|attr("label") %}
 {% set append_this = slugify(this_item_value)|trim %}
 {% do classes.append(append_this) %}
 {% endif %}
 {% endfor %}
 {% endif %}
 <div class="isotope-item {{ classes|join(" ") }}">
 {# your card code #}
 </div>
{% endfor %}
</div>

JavaScript

jQuery CDN: https://code.jquery.com/jquery-3.7.1.min.js

Isotope CDN: https://cdnjs.cloudflare.com/ajax/libs/jquery.isotope/3.0.6/isotope.pkgd.min.js

Here’s an example of the Isotope code:

let $container = $(".your-grid");

// Function to handle filter clicks
$('.data-filter').on('click', function (event) {
 var group = $(this).closest('fieldset').attr('data-group'),
 checkbox = $(this).find("input"),
 value = checkbox.attr('data-filter'),
 hasFilter = false;
 var filterGroup = filters[group];
 if (!filterGroup) { filterGroup = filters[group] = []; }
 if (checkbox.attr("checked") && value) {
 checkbox.attr("checked", false);
 $(this).removeClass("active");
 checkbox.closest("li").removeClass("active");
 if (value.indexOf(" ") > -1) {
 var value_array = value.split(" ");
 for (let x = 0; x < value_array.length; x++) {
 var this_index = filterGroup.indexOf(value_array[x]);
 filterGroup.splice(this_index, 1);
 }
 } else {
 var index = filterGroup.indexOf(value);
 filterGroup.splice(index, 1);
 }
 } else if (value) {
 checkbox.attr("checked", true);
 $(this).addClass("active");
 if (single_select) {
 $(this).siblings().removeClass('active').find("input").attr("checked", false);
 }
 if (value.indexOf(" ") > -1) {
 var value_array = value.split(" ");
 for (let i = 0; i < value_array.length; i++) {
 filterGroup.push(value_array[i]);
 }
 } else {
 filterGroup.push(value);
 }
 }
 var comboFilter = getComboFilter();
 allFilters = comboFilter;
 $container.isotope({ filter: comboFilter });
 comboFilter = "";
});

So sorry to hear that it’s been giving you trouble for weeks! I have definitely been there, so I hope this helps! Let me know if you have any questions.

This is BRILLIANT! Thank you! Macros were the bit that I was missing. I’m not familiar with writing them, so it looks like that’s the key that I need to read up on.

thank you so much! this has been VERY helpful!

Thank you again for your assistance! it has been most helpful. The two last remaining issues that I can’t work out are:
1. the hubdb fields that are TEXT and BOOLEAN are not populating.
2. the filter is not joining the queries. the results are OR instead of AND.
- I tried to apply the Isotope js for combining the filters but it just broke all the filter code.

Guidence is appreciated!

thank you

{% set this_table = 7977074 %}{% set all_items = hubdb_table_rows(this_table) %}{% set filter_columns = ["classification", "light_requirements", "soil_moisture", "grows_to", "animal_resistance", "attracts", "soil_description"] %}{# MACROS create code-friendly slugs for valuesrender HTML for filters#}{% macro slugify(string) %}{{ string|string|regex_replace("[^a-zA-Z0-9]", "-")|replace(" ","-")|replace(",","-")|replace("$","-")|replace(":","-")|replace("&","and")|lower|trim }}{% endmacro %}{# MACRO to make single filter and value #}{% macro render_filter(value, option) %}{% if type == 'boolean' %}<li class="data-filter checkbox-select" id="{{ value|trim }}" tabindex="0"><label for="{{ value|trim }}"><input type="checkbox" name="{{ value|trim }}" data-filter=".{{ value|trim }}" /><span>{{ option.name or option.label or option.value or option }}</span></label></li>{% elif type == 'text' %}<li class="data-filter checkbox-select" id="{{ value|trim }}" tabindex="0"><label for="{{ value|trim }}"><input type="checkbox" name="{{ value|trim }}" data-filter=".{{ value|trim }}" /><span>{{ option.name or option.label or option.value or option }}</span></label></li>{% else %}<li class="data-filter checkbox-select" id="{{ value|trim }}" tabindex="0"><label for="{{ value|trim }}"><input type="checkbox" name="{{ value|trim }}" data-filter=".{{ value|trim }}" /><span>{{ option.name or option.label or option }}</span></label></li>{% endif %}{% endmacro %}{% macro render_filter_group(name, filters) %}<div class="filter-group pf-accordion-item" data-filter-group="{{ name }}"><label class="pf-accordion-header"><h4>{{ name|replace('_', ' ')|lower }}<span></span></h4></label><div class="pf-accordion-content" aria-expanded="true"><fieldset class="listing-filters__group" data-group="{{ slugify(name)|trim }}" aria-expanded="true"><ul aria-hidden="false">{% for filter in filters %}{{ filter }}{% endfor %}</ul></fieldset></div></div>{% endmacro %}{# Create your filters #}<div class="filters pf-accordion">{% for column in filter_columns %}{% set options = hubdb_table_column(this_table, column).options %}{% set filters = [] %}{% for option in options %}{% set filter = render_filter(slugify(option.label), option) %}{% do filters.append(filter) %}{% endfor %}{{ render_filter_group(column, filters) }}{% endfor %}</div>{% require_js %}<script>document.addEventListener('DOMContentLoaded', function() {// Initialize Isotopevar grid = document.querySelector('.plant-card-list');var iso = new Isotope(grid, {// optionsitemSelector: '.grid-item',layoutMode: 'fitRows'});// Filter items on checkbox changevar checkboxes = document.querySelectorAll('.data-filter input[type="checkbox"]');checkboxes.forEach(function(checkbox) {checkbox.addEventListener('change', function() {var filters = [];checkboxes.forEach(function(cb) {if (cb.checked) {filters.push(cb.getAttribute('data-filter'));}});var filterValue = filters.length ? filters.join(', ') : '*';iso.arrange({ filter: filterValue });});});});// store filter for each groupvar filters = {};$demo.on( 'click', '.checkbox-select', function() {var $this = $(this);// get group keyvar $buttonGroup = $this.parents('.filter-group');var filterGroup = $buttonGroup.attr('data-filter-group');// set filter for groupfilters[ filterGroup ] = $this.attr('data-filter');// combine filtersvar filterValue = concatValues( filters );$grid.isotope({ filter: filterValue });});// flatten object by concatting valuesfunction concatValues( obj ) {var value = '';for ( var prop in obj ) {value += obj[ prop ];}return value;}// store filter for each groupvar filters = {};$demo.on( 'click', '.checkbox-select', function() {var $this = $(this);// get group keyvar $buttonGroup = $this.parents('.filter-group');var filterGroup = $buttonGroup.attr('data-filter-group');// set filter for groupfilters[ filterGroup ] = $this.attr('data-filter');// combine filtersvar filterValue = concatValues( filters );$grid.isotope({ filter: filterValue });});// flatten object by concatting valuesfunction concatValues( obj ) {var value = '';for ( var prop in obj ) {value += obj[ prop ];}return value;}</script>{% end_require_js %}

I’m so glad this has been helpful!
For the text and boolean fields try modifying the append_this to fallback on the main this_value variable.

{% if is_array %}
 {% for val in this_value %}
 {% set append_this = val.label or val.name %}
 {% do these_options.append(append_this) %}
 {% endfor %}
{% else %}
 {# change this next line to include this_value #}
 {% set append_this = this_value.label or this_value.name or this_value %}
 {% do these_options.append(append_this) %}
{% endif %}

For the JS, try using the following code to create your filter. With Isotope, the goal is for that variable to equal the classes that should select your items. So essentially, you need to combine everything into a string like “.class-one.class-two” in order to get AND filtering.

 function getComboFilter() {
 var combo = [];
 for ( var prop in filters ) {
 var group = filters[ prop ];
 if ( !group.length ) {
 combo.push( "*" );
 continue;
 }
 if ( !combo.length ) {
 combo = group.slice(0);
 continue;
 }
 var nextCombo = [];
 for ( var i=0; i < combo.length; i++ ) {
 for ( var j=0; j < group.length; j++ ) {
 var item = combo[i] + group[j];
 nextCombo.push( item );
 }
 }
 combo = nextCombo;
 }
 // this is where you join them all together to make the class string
 var comboFilter = combo.join('');
 return comboFilter;
 }

I also just realized that I never gave you the search & sort options! Here’s the JS for that:

 // helper function to throttle keydown function 
 function debounce( fn, threshold ) {
 var timeout;
 threshold = threshold || 100;
 return function debounced() {
 clearTimeout( timeout );
 var args = arguments;
 var _this = this;
 function delayed() {
 fn.apply( _this, args );
 }
 timeout = setTimeout( delayed, threshold );
 };
 }
 // searches as they type!
 var $quicksearch = $(`.listing__search input`).keyup( debounce( function() {
 qsRegex = new RegExp( $quicksearch.val(), 'gi' );
 init_grid();
 }) );

 // sorting on button click
 $(".sort-button").on("click", function() {
 let sortBy = $(this).attr("data-sort"),
 ascending = true;
 if ($(this).is(".active")) {
 container.isotope({ sortBy : 'original-order' });
 $(this).removeClass("active");
 } else {
 container.isotope({ sortBy : sortBy, sortAscending: ascending });
 $(this).addClass("active");
 }
 });

Let me know if you have any other questions!

Hello, can u please tell where i should call getComboFilter() function? I believe this is for the AND condition of the filters. I cannot see this function calling anywhere