import { FilterUi } from "./FilterUi.js";
import { EventManager } from "../../../events/EventManager.js";
import { displayMessage } from "../../../utils/utils.js";
import { UiElement } from "../../../ui/UiElement.js";
import "./KeyValueFilter.css";
export { KeyValueFilter };
/**
* A class that defines the Ui for a Text-based Key Value Filter with grouped attributes
*
* @author rhess <robin.hess@awi.de>
* @memberof vef.map.filters.ui
*/
class KeyValueFilter extends FilterUi {
class = "key-value-filter";
html = `
<div class="main-input">
<div class="key-input-wrapper"><input spellcheck="false" placeholder="key"/></div>
<div class="value-input-wrapper"><input spellcheck="false" placeholder="value" /></div>
<button class="btn-add"><i class="fas fa-plus"></i></button>
</div>
<div class="key-groups"></div>
`;
/**
* @param {HTMLElement | string} target
* @param {object} options filter specific options
* @param {LayerManager} layers Used for included/excluded layers
*/
constructor(target, options, layers) {
// apply default options
options = Object.assign({
title: "Filter By Attribute",
applyGlobalFilters: true,
values: {},
suggestions: {}
}, options || {});
super(target, options, layers);
this.filterSettings_ = null;
this.activeFilter_ = {};
this.keySuggestions_ = null;
this.staticValueSuggestions_ = null;
this.autoComplete_ = new AutoComplete(null);
this.setClass("key-value-filter");
this.setContent(this.html);
this.initFilterEvents_();
this.initSuggestions_();
this.initFilterValues_();
// update AutoComplete when layers are added or removed
this.layers_.on("layermanager_toggle_selection", (layer) => {
this.initSuggestions_();
})
this.addTool("vef vef-deselect-all", () => this.removeAll(), "Remove All Filters");
}
initFilterValues_() {
for (let key in this.options_.values) {
const group = this.options_.values[key]
for (let i = 0; i < group.length; ++i) {
this.add(key, group[i]);
}
}
}
/**
* Init event listeners
* @private
*/
initFilterEvents_() {
// events for main input
const keyInput = this.query(".key-input-wrapper input");
const valueInput = this.query(".value-input-wrapper input");
const btnAdd = this.query(".btn-add");
const apply = () => {
this.add(keyInput.value, valueInput.value);
valueInput.value = "";
}
// btnAdd.addEventListener("click", {}) // without this, this doesnt work with enter
btnAdd.addEventListener("click", apply);
valueInput.addEventListener("keydown", e => {
if (e.which == 13) { // "enter" key
this.autoComplete_.hide();
apply()
}
});
keyInput.addEventListener("keydown", e => {
if (e.which == 13) this.autoComplete_.hide(); // "enter" key
});
// autocomplete events
this.initInputSuggestions_(keyInput, valueInput);
// listen to global filter requests
if (this.options_.applyGlobalFilters) EventManager.on("add_attribute_filter", filter => {
const success = this.add(filter.column, filter.value);
this.scroll();
const message = success ? "FILTER ADDED" : "CANNOT ADD FILTER";
displayMessage(message, 5000);
});
}
/**
* @param {Layer} layer
* @private
*/
addLayerSuggestions_(layer) {
const add = (name, type) => {
if (
!this.keySuggestions_.includes(name) &&
((type == "string") || (type == "double") || (type == "int") || (type == "float"))
) {
this.keySuggestions_.push(name);
}
}
if (typeof layer.attributeFields == "object") {
for (let key in layer.attributeFields) {
const field = layer.attributeFields[key];
add(key, field.type);
}
}
}
/**
* @private
*/
initSuggestions_() {
this.keySuggestions_ = [];
this.staticValueSuggestions_ = {};
// add key suggestions from active layers
if (this.layers_) {
this.layers_.forEach(layer => {
if (layer.active) this.addLayerSuggestions_(layer);
});
}
// Add static suggestions from options
if (typeof this.options_.suggestions == "object") {
for (let key in this.options_.suggestions) {
if (!this.keySuggestions_.includes(key)) continue;
this.staticValueSuggestions_[key] = (Array.isArray(this.options_.suggestions[key]))
? this.options_.suggestions[key]
: [];
}
}
this.keySuggestions_.sort();
}
/**
* @param {HTMLElement} keyInput
* @param {HTMLElement} valueInput
* @private
*/
initInputSuggestions_(keyInput, valueInput) {
const keyCallback = () => this.autoComplete_.suggest(keyInput, this.keySuggestions_);
const valueCallback = () => {
const key = keyInput.value.trim();
if (!key) return;
// Get unique suggestions for the key from layers
let suggestions = (key in this.staticValueSuggestions_) ? [...this.staticValueSuggestions_[key]] : [];
this.autoComplete_.suggest(valueInput, suggestions);
this.layers_.forEach(async layer => {
if (!layer.active) return;
const layerSuggestions = await layer.getUniqueValues(keyInput.value);
// merge suggestions and remove duplicates
suggestions = [...new Set([...suggestions, ...layerSuggestions])];
this.autoComplete_.suggest(valueInput, suggestions);
});
}
const hide = () => this.autoComplete_.hide();
const arrowControls = (e) => {
if (!this.autoComplete_.visible) return;
if (e.which == 38) { // arrow key up
e.preventDefault();
this.autoComplete_.selectPrevious();
} if (e.which == 40) { // arrow key down
e.preventDefault();
this.autoComplete_.selectNext();
}
}
keyInput.addEventListener("input", keyCallback);
keyInput.addEventListener("focus", keyCallback);
keyInput.addEventListener("blur", hide);
keyInput.addEventListener("keydown", arrowControls);
valueInput.addEventListener("input", valueCallback);
valueInput.addEventListener("focus", valueCallback);
valueInput.addEventListener("blur", hide);
valueInput.addEventListener("keydown", arrowControls);
}
getGroup_(key) {
const element = this.getContentContainer();
const container = element.querySelector(".key-groups");
const groups = container.querySelectorAll(".key-group");
for (let i = 0; i < groups.length; ++i) {
if (groups[i].dataset.key == key) return groups[i]
}
const div = document.createElement("div");
div.classList.add("key-group", "closed");
div.dataset.key = key;
div.innerHTML = `
<div class="group-overview">
<div class="key-overview">${key}</div>
<div class="value-overview"></div>
<button class="btn-edit"><i class="fas fa-edit"></i></button>
</div>
<div class="group-inputs"></div>
`;
const btnEdit = div.querySelector(".btn-edit");
const keyOverview = div.querySelector(".key-overview");
const valueOverview = div.querySelector(".value-overview");
const icon = btnEdit.querySelector(".fas");
const clickListener = () => {
if (div.classList.contains("closed")) {
div.classList.remove("closed");
icon.classList.remove("fa-edit");
icon.classList.add("fa-times");
} else {
div.classList.add("closed");
icon.classList.remove("fa-times");
icon.classList.add("fa-edit");
}
};
btnEdit.addEventListener("click", clickListener);
keyOverview.addEventListener("click", clickListener)
valueOverview.addEventListener("click", clickListener)
container.appendChild(div);
return div;
}
reloadOptions_() {
this.setTitle(this.options_.title);
this.toggleToolVisibility("btn-power", this.options_.deactivatable);
this.fire("change", this);
}
update_() {
const element = this.getContentContainer();
const filter = {};
let valueCount = 0;
const inputs = element.querySelectorAll(".key-groups .input-item");
for (let i = 0; i < inputs.length; ++i) {
const input = inputs[i];
const key = input.querySelector(".key-input-wrapper input").value.trim();
const value = input.querySelector(".value-input-wrapper input").value.trim();
if ((key.length > 0) && (value.length > 0)) {
if (!(key in filter)) filter[key] = [];
if (!filter[key].includes(value)) {
this.getGroup_(key).querySelector(".group-inputs").appendChild(input);
filter[key].push(value);
++valueCount;
continue;
}
}
input.remove();
}
// update group overviews and remove empty groups
const groups = element.querySelectorAll(".key-groups > .key-group");
for (let i = 0; i < groups.length; ++i) {
const group = groups[i];
if (group.dataset.key in filter) {
group.querySelector(".value-overview").innerText = filter[group.dataset.key].join(", ");
} else {
group.remove();
}
}
this.activeFilter_ = filter;
this.setNotificationCount(valueCount);
this.fire("change", this);
}
removeAll() {
const inputs = this.queryAll(".key-groups .input-item");
for (let i = 0; i < inputs.length; ++i) {
inputs[i].remove();
}
this.query(".key-input-wrapper input").value = "";
this.query(".value-input-wrapper input").value = "";
this.update_();
}
add(key, value) {
const _validateInput = (item) => {
if (item == null)
return
if (typeof item != "string")
item = String(item);
return item.trim();
}
key = _validateInput(key);
value = _validateInput(value);
if (!key || !value)
return false
if (key in this.activeFilter_ && (this.activeFilter_[key].includes(value))) return true;
// create item
const item = document.createElement("div");
item.classList.add("input-item");
item.innerHTML = `
<div class="key-input-wrapper"><input spellcheck="false" placeholder="attribute"/></div>
<div class="value-input-wrapper"><input spellcheck="false" placeholder="value"/></div>
<button class="btn-add"><i class="fas fa-check"></i></button>
<button class="btn-remove"><i class="fas fa-trash"></i></button>
`;
const keyInput = item.querySelector(".key-input-wrapper input");
const valueInput = item.querySelector(".value-input-wrapper input");
const btnAdd = item.querySelector(".btn-add");
const btnRemove = item.querySelector(".btn-remove");
keyInput.value = key;
valueInput.value = value
btnAdd.addEventListener("click", () => this.update_());
valueInput.addEventListener("keydown", e => {
if (e.which == 13) { // "enter" key
this.autoComplete_.hide();
this.update_();
}
});
keyInput.addEventListener("keydown", e => {
if (e.which == 13) this.autoComplete_.hide(); // "enter" key
});
btnRemove.addEventListener("click", () => {
item.remove();
this.update_();
});
// autocomplete events
this.initInputSuggestions_(keyInput, valueInput);
this.getGroup_(key).appendChild(item);
// update ui and filter
this.update_();
return true
}
/**
* Get the filter object to pass it
* on to the Layers. Override this method in child implementations.
*
* @returns {object} filter object
*/
getActiveFilter() {
const excluded = this.getExcludedLayers();
const filters = [];
for (let key in this.activeFilter_) {
filters.push({
type: "attribute",
column: key,
values: this.activeFilter_[key].map(val => (val.includes("*")) ? ["like", val] : ["eq", val]),
excludedLayers: excluded
});
}
return filters;
}
/**
* @returns {object} options
*/
getOptions() {
const options = super.getOptions();
options.values = JSON.parse(JSON.stringify(this.activeFilter_));
return options;
}
}
class AutoComplete extends UiElement {
constructor(target) {
super(target, {});
this.currentResults = [];
this.target_ = null;
this.selectedIndex = null;
this.visible = false;
const element = this.getElement();
element.classList.add("auto-complete");
element.tabindex = 1;
}
selectIndex(index) {
if (this.currentResults.length == 0) {
this.selectedIndex = null;
return;
}
if (Number.isFinite(this.selectedIndex)) this.currentResults[this.selectedIndex].classList.remove("selected");
if (Number.isFinite(index) && (index >= 0) && (index < this.currentResults.length)) {
this.selectedIndex = index;
const result = this.currentResults[index];
result.classList.add("selected");
if (this.target_) this.target_.value = result.title;
} else {
this.selectedIndex = null;
}
}
selectNext() {
this.selectIndex((Number.isFinite(this.selectedIndex)) ? (this.selectedIndex + 1) : 0);
}
selectPrevious() {
this.selectIndex((Number.isFinite(this.selectedIndex)) ? (this.selectedIndex - 1) : (this.currentResults.length - 1));
}
suggest(input, suggestions) {
if (this.target_ != input) {
this.target_ = input;
this.appendTo(input.parentElement);
}
if (!Array.isArray(suggestions)) {
this.hide();
return;
}
const element = this.getElement();
element.innerHTML = "";
const value = input.value.toLowerCase();
this.currentResults = []
this.selectedIndex = null;
for (let i = 0; i < suggestions.length; ++i) {
const index = suggestions[i].toLowerCase().indexOf(value);
if (index < 0) continue;
const div = document.createElement("div");
div.classList.add("suggestion");
div.innerText = suggestions[i];
div.title = suggestions[i];
// click does not work, because it has a lower priority than blur/focusout of the input
div.addEventListener("pointerdown", e => {
this.hide();
input.value = div.title;
});
element.appendChild(div);
this.currentResults.push(div);
}
if (this.currentResults.length == 0) {
this.hide();
return;
}
this.show();
}
hide() {
this.getElement().style.display = "none";
this.visible = false;
}
show() {
this.getElement().style.display = "block";
this.visible = true;
}
}