Using isotope.js to define an AND relationship when filtering using multiple options

156 Views Asked by At

I have two content type areas which contain unique filter options. These are:

  1. type
  2. tag

I'm trying to utilise isotope.js, to achieve a dual filtering layout, but it always gives the last clicked filter priority.

The way I want it to function is:

  1. If only one tag is selected, then show results with that tag
  2. If more than one tag is selected, then show results that have any of those tags (both do not need to exist together on a card)
  3. If a type is selected, only show results that fall under that type
  4. If one type is selected and one tag, show results for posts where the tag exists on the selected type
  5. If one type is selected and multiple tags, then show posts that fall under that type that have either of those tags.
  6. If more than one type is selected and one or more tags, then show posts where the tags exist on either type

For example, using my demo below, here are some use cases:

  1. If I click on "Video Demos & Tours", I should see two video posts (card 1 and 3) - WORKS
  2. If I click on "Video Demos & Tours" and then "Ansible", I should see only see card 3 - DOESN'T WORK
  3. If I click on "Blog & News", I should see 3 cards (card 2, 4, 5) - WORKS
  4. If I click on "Blog & News" and then "Ansible", I should see cards 4 and 5
  5. If I click on "Blog & News", "Ansible" and then "Automation", I should see cards 2,4 and 5

However, in my current demo, though the console log seems to be on the right lines, it doesn't perform the way I intent it to.

document.addEventListener("DOMContentLoaded", function () {
  var container = document.querySelector(".grid");
  var gridItems = container.querySelectorAll(".grid-item");
  const optionLinks = document.querySelectorAll(".rSidebar__options-li");

  var iso = new Isotope(container, {
    itemSelector: ".resourceCard",
    layoutMode: "fitRows",
    transitionDuration: "0.5s"
  });

  var filters = {};

  function concatValues(obj) {
    var value = [];
    for (var prop in obj) {
      value.push(obj[prop]);
    }
    return value.flat().join(", ");
  }

  function handleFilterClick(event, filters) {
    var listItem = event;
    var filterGroup = listItem
      .closest(".rSidebar__options")
      .getAttribute("data-filter-group");
    var data_filter = listItem.getAttribute("data-filter");

    if (filterGroup === "type") {
      filters[filterGroup] = [data_filter];
    } else {
      if (!filters[filterGroup]) {
        filters[filterGroup] = [];
      }

      if (listItem.classList.contains("selected")) {
        filters[filterGroup].push(data_filter);
      } else {
        filters[filterGroup] = filters[filterGroup].filter(
          (tag) => tag !== data_filter
        );
      }
    }

    // Combine the type filter with the selected tag filters using an AND relationship
    var filterValues = [];

    // Handle type filter
    if (filters["type"]) {
      filterValues.push("." + filters["type"][0]);
    }

    // Handle tags filter if it's defined
    if (filters["tag"]) {
      var selectedType = filters["type"][0];
      filters["tag"].forEach((tag) => {
        filterValues.push("." + selectedType + "." + tag);
      });
    }

    var finalFilter = filterValues.join(", ");
    console.log(finalFilter);

    iso.arrange({
      filter: finalFilter
    });
  }

  optionLinks.forEach(function (optionLink) {
    optionLink.addEventListener("click", function (event) {
      event.preventDefault();
      this.classList.toggle("selected");
      handleFilterClick(this, filters);
    });
  });
});
.post {
  padding: 100px;
}

.rSidebar__box {
  margin-bottom: 30px;
}
.rSidebar__options {
  padding-left: 0;
}
.rSidebar__options-li {
  margin-bottom: 17px;
  display: flex;
  align-items: center;
  cursor: pointer;
  width: fit-content;
}
.rSidebar__options-li.selected .rSidebar__options-square {
  background-color: #185A7D;
}
.rSidebar__options-square {
  height: 20px;
  width: 20px;
  transition: all 0.5s ease;
  border: 2px solid #000000;
}
.rSidebar__options-label {
  margin-left: 10px;
}

.grid {
  display: flex;
  flex-wrap: wrap;
  margin: -14px 0 0 -14px;
}
.grid-item {
  box-sizing: border-box;
  width: calc(45% - 14px);
  margin: 14px 0 18px 14px;
  border: 2px solid black;
  padding: 20px;
}
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<script src="https://unpkg.com/isotope-layout@3/dist/isotope.pkgd.min.js"></script>


<div class="post">
  <div class="container">
    <div class="row justify-content-between">

      <!-- SIDEBAR -->
      <div class="col-3">
        <div class="rSidebar">

          <!-- tags -->
          <div class="rSidebar__box">
            <span class="rSidebar__label d-block fw-bold">Filter by tag</span>
            <ul class="rSidebar__options button-group" data-filter-group="tag">
              <li class="rSidebar__options-li" data-filter="ansible">
                <span class="rSidebar__options-square"></span>
                <span class="rSidebar__options-label d-block" data-filter="ansible">Ansible</span>
              </li>
                <li class="rSidebar__options-li" data-filter="automation">
                <span class="rSidebar__options-square"></span>
                <span class="rSidebar__options-label d-block" data-filter="automation">Automation</span>
              </li>
            </ul>
          </div>

          <!--  type -->
          <div class="rSidebar__box">
            <span class="rSidebar__label d-block fw-bold">Filter by type</span>
            <ul class="rSidebar__options button-group" data-filter-group="type">
              <li class="rSidebar__options-li" data-filter="blogs-and-news">
                <span class="rSidebar__options-square"></span>
                <span class="rSidebar__options-label d-block" data-filter="blogs-and-news">Blog & News</span>
              </li>
              <li class="rSidebar__options-li" data-filter="video-demos-tour">
                <span class="rSidebar__options-square"></span>
                <span class="rSidebar__options-label d-block" data-filter="video-demos-tours">Video Demos & Tours</span>
              </li>
            </ul>
          </div>
          <!-- end -->
        </div>
      </div>
      <!-- END -->

      <!-- GRID -->
      <div class="col-7">
        <div class="grid">
          <article class="resourceCard grid-item video-demos-tour automation"><span class="resourceCard__body-title">Card 1<br>Type: Video Demo & Tour<br>Tag: Automation</span></article>
          <article class="resourceCard grid-item blogs-and-news"><span class="resourceCard__body-title">Card 2<br>Type: Blog & News<br>Tag: Automation</span></article>
          <article class="resourceCard grid-item video-demos-tour automation ansible"><span class="resourceCard__body-title">Card 3<br>Type: Video Demo & Tour<br>Tags: Automation, Ansible</span></article>
          <article class="resourceCard grid-item blogs-and-news ansible"><span class="resourceCard__body-title">Card 4<br>Type: Blog & News<br>Tag: Ansible</span></article>
          <article class="resourceCard grid-item blogs-and-news ansible"><span class="resourceCard__body-title">Card 5<br>Type: Blog & News<br>Tags: Ansible, Automations</span></article>
        </div>
      </div>
      <!-- END -->

    </div>
  </div>
</div>

1

There are 1 best solutions below

1
Moritz Ringler On

If I understand correctly, you want to combine filters of the same group with OR and between groups with AND:

{a,b} x {1,2} -> a1|a2|b1|b2

In selectors (as used by Isotope), that would be:

.a.1, .a.2, .b.1, .b.2

You can build this by going over each group, combining every element with the previously build selectors:

function buildSelectors(filterObject){
  return Object.values(filterObject)  // extract filter arrays
    .reduce(
      (selectors, groupValues) => 
        groupValues.length === 0 ? selectors :  // skip empty groups
        groupValues.flatMap(v => selectors.map(s => `${s}.${v}`)) // combine group elements with existing selectors into new list
      , [''])
    .join(', ')
}

If you prefer regular loops, that is something like:

function buildSelectors(filterObject){
  let selectors = ['']
  for (const group of Object.values(filterObject)){
    if (group.length === 0) {
      continue
    }
    const mergedSelectors = []
    for (const value of group) {
      for (const selector of selectors) {
        mergedSelectors.push(`${selector}.${value}`)
      }
    }
    selectors = mergedSelectors
  }
  return selectors.join(',')
}

Some general hints and recommendations:

  • Card 2 and Card 5 miss the automation class, not sure if that is intentional
  • Instantiate the filter object with empty arrays for all existing groups, it spares you figuring out if the group property already exists later on
  • Separate DOM operations from filter data operations, it makes the code more maintainable
  • Consider creating a class for filter to further split up the code by concern

Have a look at the updated snippet:

class Filter{
  groups = {
    tag: [],
    type: [],
  }
  
  updateGroup(group, value, isSelected) {
    const isActiveValue = this.groups[group].includes(value)
    if (isSelected && !isActiveValue){
       this.groups[group].push(value)
    }
    if (!isSelected && isActiveValue){
       this.groups[group] = this.groups[group].filter(v => v !== value)
    }
  }

  buildSelector(){
    return Object.values(this.groups).reduce(
      (selectors, groupValues) => 
        groupValues.length === 0 ? selectors :
        groupValues.flatMap(v => selectors.map(s => `${s}.${v}`))
      , [''])
    .join(', ')
  }
  
  updateSelector(group, value, isSelected){
    this.updateGroup(group, value, isSelected)
    return this.buildSelector()
  }
}

document.addEventListener("DOMContentLoaded", function () {

  const iso = new Isotope('.grid', {
    itemSelector: ".resourceCard",
    layoutMode: "fitRows",
    transitionDuration: "0.5s"
  });

  const activeFilter = new Filter()
  
  function onOptionLinkClick(event){
    event.preventDefault();
    
    this.classList.toggle("selected");
    const isSelected = this.classList.contains("selected")
    
    const group = this
      .closest(".rSidebar__options")
      .getAttribute("data-filter-group");
    const value = this.getAttribute("data-filter");

    const filter = activeFilter.updateSelector(group, value, isSelected)
    iso.arrange({filter})
    
    console.clear()
    console.log('Current filter:', filter)
  }

  const optionLinks = document.querySelectorAll(".rSidebar__options-li");
  optionLinks.forEach(optionLink => optionLink.addEventListener("click", onOptionLinkClick));
})
.post {
  padding: 10px;
}

.rSidebar__box {
  margin-bottom: 30px;
}
.rSidebar__options {
  padding-left: 0;
}
.rSidebar__options-li {
  margin-bottom: 17px;
  display: flex;
  align-items: center;
  cursor: pointer;
  width: fit-content;
}
.rSidebar__options-li.selected .rSidebar__options-square {
  background-color: #185A7D;
}
.rSidebar__options-square {
  height: 20px;
  width: 20px;
  transition: all 0.5s ease;
  border: 2px solid #000000;
}
.rSidebar__options-label {
  margin-left: 10px;
}

.grid {
  display: flex;
  flex-wrap: wrap;
  margin: -14px 0 0 -14px;
}
.grid-item {
  box-sizing: border-box;
  width: calc(45% - 14px);
  margin: 14px 0 18px 14px;
  border: 2px solid black;
  padding: 20px;
}
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<script src="https://unpkg.com/isotope-layout@3/dist/isotope.pkgd.min.js"></script>


<div class="post">
  <div class="container">
    <div class="row justify-content-between">

      <!-- SIDEBAR -->
      <div class="col-3">
        <div class="rSidebar">
          
          <!-- tags -->
          <div class="rSidebar__box">
            <span class="rSidebar__label d-block fw-bold">Filter by tag</span>
            <ul class="rSidebar__options button-group" data-filter-group="tag">
              <li class="rSidebar__options-li" data-filter="ansible">
                <span class="rSidebar__options-square"></span>
                <span class="rSidebar__options-label d-block" data-filter="ansible">Ansible</span>
              </li>
                <li class="rSidebar__options-li" data-filter="automation">
                <span class="rSidebar__options-square"></span>
                <span class="rSidebar__options-label d-block" data-filter="automation">Automation</span>
              </li>
            </ul>
          </div>

          <!--  type -->
          <div class="rSidebar__box">
            <span class="rSidebar__label d-block fw-bold">Filter by type</span>
            <ul class="rSidebar__options button-group" data-filter-group="type">
              <li class="rSidebar__options-li" data-filter="blogs-and-news">
                <span class="rSidebar__options-square"></span>
                <span class="rSidebar__options-label d-block" data-filter="blogs-and-news">Blog & News</span>
              </li>
              <li class="rSidebar__options-li" data-filter="video-demos-tour">
                <span class="rSidebar__options-square"></span>
                <span class="rSidebar__options-label d-block" data-filter="video-demos-tours">Video Demos & Tours</span>
              </li>
            </ul>
          </div>
          <!-- end -->
        </div>
      </div>
      <!-- END -->

      <!-- GRID -->
      <div class="col-7">
        <div class="grid">
          <article class="resourceCard grid-item video-demos-tour automation"><span class="resourceCard__body-title">Card 1<br>Type: Video<br>Tag: Automation</span></article>
          <article class="resourceCard grid-item blogs-and-news automation"><span class="resourceCard__body-title">Card 2<br>Type: Blog<br>Tag: Automation</span></article>
          <article class="resourceCard grid-item video-demos-tour automation ansible"><span class="resourceCard__body-title">Card 3<br>Type: Video<br>Tags: Automation, Ansible</span></article>
          <article class="resourceCard grid-item blogs-and-news ansible"><span class="resourceCard__body-title">Card 4<br>Type: Blog<br>Tag: Ansible</span></article>
          <article class="resourceCard grid-item blogs-and-news ansible automation"><span class="resourceCard__body-title">Card 5<br>Type: Blog<br>Tags: Ansible, Automation</span></article>
          <article class="resourceCard grid-item video-demos-tour ansible"><span class="resourceCard__body-title">Card 6<br>Type: Video <br>Tags: Ansible</span></article>
        </div>
      </div>
      <!-- END -->

    </div>
  </div>
</div>

Does that make sense, does it help?