function* renderHierarchyPicker(
levels,
{
showTitle = true,
createSelector = defaultCreateSelector,
elementId = null,
getSelect = (node) => {
const $node = $(node);
return $node.prop("tagName") === "SELECT" ? $node : $node.find("select");
},
multiple = false,
onSelect = () => {},
showIntermediate = false,
defaultToFirst = true,
width = "20em",
wrapSelectors = (selectors) => html`
<div
class="cascading-hierarchy-picker"
style="display: flex; flex-direction: row; overflow-x: scroll;"
>
${joinArray(selectors, " ")}
</div>`,
showAddAll = false
} = {}
) {
select2;
const onAddAll =
multiple && showAddAll
? (level, selectElement, depth) => {
if (!!level.cachedOptions) {
const optionIds = level.cachedOptions.map((x) => x.id);
$(selectElement).val(optionIds);
$(selectElement).trigger("change");
}
}
: null;
const selectors = levels.map((level, i) =>
createSelector(
level,
i,
showTitle,
level.selectOptions?.maximumSelectionLength ? null : onAddAll
)
);
// Guard against custom createSelectors that don't have setAddAllEnabled:
const setAddAllEnabled = (selector, isEnabled) => {
if (!!selector.setAddAllEnabled) {
selector.setAddAllEnabled(isEnabled);
}
};
const wrap = html`
<div ${!!elementId ? `id="${elementId}"` : ""} class="table-select">
<style>
.table-select {
font-family: 'Questrial', sans-serif;
font-weight: bold;
}
.select2-dropdown, ._select2-selection__rendered {
font-family: 'Questrial', sans-serif;
font-weight: bold;
}
</style>
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Questrial&display=swap" rel="stylesheet">
${wrapSelectors(selectors)}
</div>`;
// value.levels: [{id: "l1", title: "Level 1", ...}, ...]
// value.selections: currently selected ones. So: {l1: ["A2"], l2: ["B2", "B3"]}
// This function dispatches events if anything changes.
const setValue = (value) => {
const [v1, v2] = [JSON.stringify(value), JSON.stringify(wrap.value)];
const isDifferent = v1 !== v2;
if (isDifferent && (showIntermediate || value.selectionComplete)) {
wrap.value = _.cloneDeep(value);
wrap.dispatchEvent(new CustomEvent("input"));
onSelect(wrap.value);
}
};
const getValue = () => wrap.value;
const isValidValue = (candidate) =>
multiple ? candidate?.length > 0 : !!candidate;
// finds items that match what you typed
const matcher = ({ term }, data) => {
if (!term || term.length === 0) return data;
const search = term.toLowerCase();
return data.text.toLowerCase().indexOf(search) < 0
? null
: {
...data,
editDistance: editDistance(search, data.id)
};
};
const sorter = (options) => {
return options.sort((a, b) => b.editDistance - a.editDistance);
};
const defaultOptions = {
width,
allowClear: true,
matcher,
sorter,
multiple
};
const selections = {};
// Clears all selections.
const resetChildren = (levelIndex, isClear) => {
return selectors.forEach((selector, i) => {
if (isClear && i === levelIndex) {
delete selections[levels[i].id];
getSelect(selector).val(null).trigger("change");
}
if (i > levelIndex) {
delete selections[levels[i].id];
getSelect(selector)
.empty()
.select2({ ...defaultOptions, disabled: true })
.val(null)
.trigger("change");
setAddAllEnabled(selector, false);
}
});
};
// Runs when a new level becomes available.
function activateLevel(levelIndex) {
const {
title,
getOptions,
defaultValueId,
selectOptions = {}
} = levels[levelIndex];
const selectionArray = levels
.filter((_, i) => i < levelIndex)
.map(({ id }) => selections[id]);
wrapAsPromise(getOptions(selectionArray, levelIndex, levels)).then(
(options) => {
const defaultValue =
isValidValue(defaultValueId) &&
(multiple
? options.filter((d) => wrapAsArray(defaultValueId).includes(d.id))
: options.find((d) => d.id === defaultValueId));
const foundDefaultValue =
!isValidValue(defaultValue) && defaultToFirst
? multiple
? [options[0]]
: options[0]
: defaultValue;
getSelect(selectors[levelIndex])
.empty()
.select2({
...defaultOptions,
placeholder: `Select ${title}`,
data: options,
disabled: false,
...selectOptions
})
.val(null)
.trigger("change");
if (isValidValue(foundDefaultValue)) {
getSelect(selectors[levelIndex])
.val(
multiple
? foundDefaultValue.map((d) => d.id)
: foundDefaultValue.id
)
.trigger("change");
}
resetChildren(levelIndex);
// Cache the options, so the addAllButton knows what to select
levels[levelIndex].cachedOptions = options;
setAddAllEnabled(selectors[levelIndex], true);
}
);
}
// Runs when you select something
const handleLevelSelect = (value, levelIndex) => {
if (!isValidValue(value)) {
return;
}
selections[levels[levelIndex].id] = value;
const isLastLevel = levelIndex === levels.length - 1;
if (!isLastLevel) {
levels
.filter((_, idx) => idx > levelIndex)
.forEach((level, i) => {
// e.g. if you handleLevelSelect(levelIndex=2), disable the addAll for levels 3 and 4 (i=0, 1)
setAddAllEnabled(selectors[levelIndex + i + 1], false);
delete level.cachedOptions;
delete selections[level.id];
});
activateLevel(levelIndex + 1);
}
setValue({
selections,
selectionComplete: isLastLevel,
levelIndex,
levels
});
};
// initialize the levels
levels.forEach((level, levelIndex) => {
const select = getSelect(selectors[levelIndex]);
select
.on("change", (event) => {
const value = select.val();
if (!!value) {
handleLevelSelect(value, levelIndex);
}
})
.on("select2:clear", () => {
resetChildren(levelIndex, true);
setValue({
selections,
selectionComplete: false,
levelIndex,
levels
});
})
.select2({ ...defaultOptions, ...level.customOptions, disabled: true })
.empty();
setAddAllEnabled(selectors[levelIndex], true);
});
activateLevel(0);
setValue({
selections,
selectionComplete: false,
levels,
initialUpdate: true
});
yield wrap;
}