Source: map/filters/ui/KeyValueFilter.js

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;
    }

}