Hierarchical dependent dropdowns allow subsequent dropdown filters to exclude any irrelevant data based on other selections. For example, you may have a series of filters for state and city. Once the user selects a state, you want the options for the city dropdown to exclude any cities not in that state.
This code was originally developed by the Get \_\_it Done YouTube channel in the playlist [Dependent Dropdowns - Unlimited Levels](https://youtube.com/playlist?list=PLRmEk9smitaUDco5Y_bYClnFNCHx1jmWY&si=fzFzipQ6aEDyhEIw). I've adapted it slightly, including to use the keyword "All" to include all values at that filter.
I've adapted the code and used it in projects including the [USAID/Guatemala Data Catalog](https://github.com/eriktuck/gtm-apps/tree/main/apps/data-catalog) and the [Environmental Incentives Look Book](https://script.google.com/home/projects/1wV9OqFVBBvQSPizbfo392BIckRHPH9OxKtQTPY0GzXqa1YRkwFLLEmB5/edit). I use `.includes()` to allow the most inclusive filtering, you could instead ask for exact match in the `filterData` method.
To use this code, first copy the `DDropdown` class into your project. Note that this class relies on global variables `DATA`, `HEADERS`, and `DDCOLS`, which are specified in subsequent code blocks below.
```JavaScript
class DDropDown{
// https://www.youtube.com/playlist?list=PLRmEk9smitaUDco5Y_bYClnFNCHx1jmWY
constructor(data){
this.data = data;
this.targets=[];
}
filterData(filtersAsArray){
// Use 'All' as option in filter to get All values
const filteredData = this.data.filter(r => {
return filtersAsArray.every((item, i) => {
return item === 'All' || item === '' ? true : r[i].includes(item)
})
});
return filteredData
}
getUniqueValues(dataAsArray, index){
const uniqueOptions = new Set();
dataAsArray.forEach(r => {
return r[index].split(',').forEach(item => {
return item !== '' ? uniqueOptions.add(item.trim()) : {}
})
});
return [...uniqueOptions].sort();
}
populateDropDown(el, listAsArray){
const currentValue = el.value;
// clear options
el.innerHTML = "";
// add All as option
const allOption = document.createElement('option');
allOption.textContent='All';
el.appendChild(allOption);
// add dependent options
listAsArray.forEach(item => {
const option = document.createElement('option');
option.textContent = item;
el.appendChild(option);
});
// set value
el.value = currentValue === '' ? 'All' : currentValue;
}
createPopulateDropDownFunction(el, elsDependsOn, elIndex){
return () => {
var elsDependsOnValues = elsDependsOn.length === 0 ? null : elsDependsOn.map(depEl => depEl.value);
elsDependsOnValues.splice(elIndex, 0, '');
const dataToUse = elsDependsOn.length === 0 ? this.data : this.filterData(elsDependsOnValues);
const listToUse = this.getUniqueValues(dataToUse, elIndex);
this.populateDropDown(el, listToUse);
}
}
applyFilters() {
return () => {
var filterEls = document.querySelectorAll(".filters");
var filtersAsArray = [];
filterEls.forEach(el => filtersAsArray.push(el.value));
var filterIndices = DDCOLS.map(col => HEADERS.indexOf(col));
var filteredData = DATA.filter(row => {
return filtersAsArray.every((item, i) => {
return item === 'All' || item === '' ? true : row[filterIndices[i]].includes(item);
});
});
setFilterData(filteredData);
}
}
add(options){
/* options = {target: "level2", dependsOn: ["level1"], elIndex: 2, elProp: 'prop1'} */
const el = document.getElementById(options.target);
const elsDependsOn = options.dependsOn.length === 0 ? [] : options.dependsOn.map(id => {
return document.getElementById(id)
});
const elsAsArray = elsDependsOn.concat(el);
const initFunction = this.createPopulateDropDownFunction(el, elsDependsOn, options.elIndex);
const changeFunction = getResults;
const targetObject = { el: el, elsDependsOn: elsDependsOn, func: initFunction, elProp: options.elProp };
el.addEventListener('change', changeFunction);
targetObject.elsDependsOn.forEach(depEl => depEl.addEventListener('change', initFunction));
this.targets.push(targetObject);
return this;
}
initialize(){
this.targets.forEach(t => t.func());
return this;
}
eazyDropDown(arrayOfIds, arrayOfProps){ // TODO could be single dictionary as input {el: {target: elId, elProp: 'Name'}}
arrayOfIds.forEach((item, i) => {
const dependsOn = arrayOfIds.filter(val => val !== item);
const option = { target: item, dependsOn: dependsOn, elIndex: i, elProp: arrayOfProps[i]};
this.add(option);
});
this.initialize();
}
}
```
To initialize the Dropdowns I write a `createDropDowns()` function to be called by the function `initializeApp()` after the DOM content is loaded. The example below is boilerplate for a Google Apps Script [[web app]].
```JavaScript
let HEADERS;
let DATA;
let DDCOLS = ['State', 'City']; // order by display, should exactly match columns in spreadsheet
let ELIDS = ['state', 'city'] // match in index.html
// set data
function setData(dataReturned){
HEADERS = dataReturned.shift();
DATA = dataReturned.slice();
};
// create DropDown menus
function createDropDowns(){
var ddColIdxs = DDCOLS.map(col => HEADERS.indexOf(col));
var ddData = DATA.map(row => {
return row.filter((_, idx) => ddColIdxs.includes(idx))
});
var dd = new DDropDown(ddData);
dd.eazyDropDown(
ELIDS,
DDCOLS
);
}
// populate UI
function populateCards(data) {
if(data){
// Do something with filtered data
};
}
// initialize app
function initializeApp(dataReturned){
setData(dataReturned);
createDropDowns();
populateCards(DATA); // Populate UI initially
}
// Event listener
document.addEventListener('DOMContentLoaded', function(){
// Get data
google.script.run.withSuccessHandler(initializeApp).getData();
});
```
In the above code, `getData()` is a simple function in `Code.gs` for getting data as an array; customize for your application.
In my HTML I'll use [[base/Web/Bootstrap]] to include the `<select>` elements within a form group that will function as dropdown filters.
```HTML
<!-- Filters -->
<div id="filterContainer" class="rounded bg-light">
<div class="d-grid gap-2">
<button type="button" class="btn btn-block btn-light" data-bs-toggle="collapse" data-bs-target="#filters">Filter Products</button>
</div>
<div id="filters" class="collapse show">
<form>
<div class="form-group">
<label for="state">State</label>
<select id="state" class="form-select filters" aria-label="Select a State">
</select>
</div>
<div class="form-group">
<label for="city">City</label>
<select id="city" class="form-select filters" aria-label="Select a City">
</select>
</div>
</form>
</div>
```
Finally, you'll just need to include a `<div>` to write out the results.