summaryrefslogtreecommitdiffstats
path: root/asset/js/widget
diff options
context:
space:
mode:
Diffstat (limited to 'asset/js/widget')
-rw-r--r--asset/js/widget/BaseInput.js1049
-rw-r--r--asset/js/widget/Completer.js750
-rw-r--r--asset/js/widget/CopyToClipboard.js49
-rw-r--r--asset/js/widget/FilterInput.js1521
-rw-r--r--asset/js/widget/SearchBar.js81
-rw-r--r--asset/js/widget/SearchEditor.js79
-rw-r--r--asset/js/widget/TermInput.js196
7 files changed, 3725 insertions, 0 deletions
diff --git a/asset/js/widget/BaseInput.js b/asset/js/widget/BaseInput.js
new file mode 100644
index 0000000..269d6f7
--- /dev/null
+++ b/asset/js/widget/BaseInput.js
@@ -0,0 +1,1049 @@
+define(["../notjQuery", "Completer"], function ($, Completer) {
+
+ "use strict";
+
+ class BaseInput {
+ constructor(input) {
+ this.input = input;
+ this.disabled = false;
+ this.separator = '';
+ this.usedTerms = [];
+ this.completer = null;
+ this.lastCompletedTerm = null;
+ this.manageRequired = input.required;
+ this._dataInput = null;
+ this._termInput = null;
+ this._termContainer = null;
+ }
+
+ get dataInput() {
+ if (this._dataInput === null) {
+ this._dataInput = document.querySelector(this.input.dataset.dataInput);
+ }
+
+ return this._dataInput;
+ }
+
+ get termInput() {
+ if (this._termInput === null) {
+ this._termInput = document.querySelector(this.input.dataset.termInput);
+ }
+
+ return this._termInput;
+ }
+
+ get termContainer() {
+ if (this._termContainer === null) {
+ this._termContainer = document.querySelector(this.input.dataset.termContainer);
+ }
+
+ return this._termContainer;
+ }
+
+ bind() {
+ // Form submissions
+ $(this.input.form).on('submit', this.onSubmit, this);
+ $(this.input.form).on(
+ 'click', 'button:not([type]), button[type="submit"], input[type="submit"]', this.onButtonClick, this);
+
+ // User interactions
+ $(this.input).on('input', this.onInput, this);
+ $(this.input).on('keydown', this.onKeyDown, this);
+ $(this.input).on('keyup', this.onKeyUp, this);
+ $(this.input).on('blur', this.onInputBlur, this);
+ $(this.input).on('focusin', this.onTermFocus, this);
+ $(this.termContainer).on('input', '[data-label]', this.onInput, this);
+ $(this.termContainer).on('keydown', '[data-label]', this.onKeyDown, this);
+ $(this.termContainer).on('keyup', '[data-label]', this.onKeyUp, this);
+ $(this.termContainer).on('focusout', '[data-index]', this.onTermFocusOut, this);
+ $(this.termContainer).on('focusin', '[data-index]', this.onTermFocus, this);
+
+ // Copy/Paste
+ $(this.input).on('paste', this.onPaste, this);
+ $(this.input).on('copy', this.onCopyAndCut, this);
+ $(this.input).on('cut', this.onCopyAndCut, this);
+
+ // Should terms be completed?
+ if (this.input.dataset.suggestUrl) {
+ if (this.completer === null) {
+ this.completer = new Completer(this.input, true);
+ this.completer.bind(this.termContainer);
+ }
+
+ $(this.input).on('suggestion', this.onSuggestion, this);
+ $(this.input).on('completion', this.onCompletion, this);
+ $(this.termContainer).on('suggestion', '[data-label]', this.onSuggestion, this);
+ $(this.termContainer).on('completion', '[data-label]', this.onCompletion, this);
+ }
+
+ return this;
+ }
+
+ refresh(input) {
+ if (input === this.input) {
+ // If the DOM node is still the same, nothing has changed
+ return;
+ }
+
+ this._termInput = null;
+ this._termContainer = null;
+
+ this.input = input;
+ this.bind();
+
+ if (this.completer !== null) {
+ this.completer.refresh(input, this.termContainer);
+ }
+
+ if (! this.restoreTerms()) {
+ this.reset();
+ }
+ }
+
+ reset() {
+ this.usedTerms = [];
+ this.lastCompletedTerm = null;
+
+ this.togglePlaceholder();
+ this.termInput.value = '';
+ this.termContainer.innerHTML = '';
+ }
+
+ destroy() {
+ this._termContainer = null;
+ this._termInput = null;
+ this.input = null;
+
+ if (this.completer !== null) {
+ this.completer.destroy();
+ this.completer = null;
+ }
+ }
+
+ disable() {
+ this.disabled = true;
+ this.input.disabled = true;
+ this.input.form.classList.add('disabled');
+ this.termContainer.querySelectorAll('[data-index]').forEach(el => el.firstChild.disabled = true);
+
+ if (this.completer !== null) {
+ this.completer.reset();
+ }
+ }
+
+ enable() {
+ this.input.disabled = false;
+ this.input.form.classList.remove('disabled');
+ this.termContainer.querySelectorAll('[data-index]').forEach(el => el.firstChild.disabled = false);
+ this.disabled = false;
+ }
+
+ restoreTerms() {
+ if (this.hasTerms()) {
+ this.usedTerms.forEach((termData, termIndex) => this.addTerm(termData, termIndex));
+ this.togglePlaceholder();
+ this.clearPartialTerm(this.input);
+ } else {
+ this.registerTerms();
+ this.togglePlaceholder();
+ }
+
+ if (this.hasTerms()) {
+ if (this.manageRequired) {
+ this.input.required = false;
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ registerTerms() {
+ this.termContainer.querySelectorAll('[data-index]').forEach((label) => {
+ let termData = { ...label.dataset };
+ delete termData.index;
+
+ if (label.className) {
+ termData['class'] = label.className;
+ }
+
+ if (label.title) {
+ termData['title'] = label.title;
+ }
+
+ this.registerTerm(this.decodeTerm(termData), label.dataset.index);
+ });
+ }
+
+ registerTerm(termData, termIndex = null) {
+ if (termIndex !== null) {
+ this.usedTerms.splice(termIndex, 0, termData);
+ return termIndex;
+ } else {
+ return this.usedTerms.push(termData) - 1;
+ }
+ }
+
+ updateTerms(changedTerms) {
+ // Reset the data input, otherwise the value remains and is sent continuously with subsequent requests
+ this.dataInput.value = '';
+
+ if (changedTerms === 'bogus') {
+ return;
+ }
+
+ let changedIndices = Object.keys(changedTerms);
+ if (! changedIndices.length) {
+ // Perform a partial reset. this.reset() empties the termContainer, which isn't desired here
+ this.usedTerms = [];
+ this.lastCompletedTerm = null;
+
+ this.registerTerms();
+ this.togglePlaceholder();
+ this.termInput.value = '';
+ }
+
+ for (const termIndex of changedIndices) {
+ let label = this.termContainer.querySelector(`[data-index="${ termIndex }"]`);
+ if (! label) {
+ continue;
+ }
+
+ let input = label.firstChild;
+ let termData = changedTerms[termIndex];
+
+ if (termData.label) {
+ this.writePartialTerm(termData.label, input);
+ }
+
+ this.updateTermData(termData, input);
+ this.usedTerms[termIndex] = termData;
+ }
+ }
+
+ clearPartialTerm(input) {
+ if (this.completer !== null) {
+ this.completer.reset();
+ }
+
+ this.writePartialTerm('', input);
+ }
+
+ writePartialTerm(value, input) {
+ input.value = value;
+ this.updateTermData({ label: value }, input);
+ }
+
+ readPartialTerm(input) {
+ return input.value.trim();
+ }
+
+ readFullTerm(input, termIndex = null) {
+ let value = this.readPartialTerm(input);
+ if (! value && this.lastCompletedTerm === null) {
+ return false;
+ }
+
+ let termData = {};
+
+ if (termIndex !== null) {
+ termData = { ...this.usedTerms[termIndex] };
+ }
+
+ if (value) {
+ termData.label = value;
+ termData.search = value;
+ }
+
+ if (this.lastCompletedTerm !== null) {
+ if ('type' in this.lastCompletedTerm && this.lastCompletedTerm.type === 'terms') {
+ if (typeof this.lastCompletedTerm.terms === 'string') {
+ termData = JSON.parse(this.lastCompletedTerm.terms);
+ } else {
+ termData = this.lastCompletedTerm.terms;
+ }
+ } else if (termData.label === this.lastCompletedTerm.label) {
+ Object.assign(termData, this.lastCompletedTerm);
+ }
+
+ this.lastCompletedTerm = null;
+ }
+
+ return termData;
+ }
+
+ exchangeTerm() {
+ if (this.completer !== null) {
+ this.completer.reset();
+ }
+
+ let termData = this.readFullTerm(this.input);
+ if (! termData) {
+ return {};
+ }
+
+ let addedTerms = {};
+ if (Array.isArray(termData)) {
+ for (let data of termData) {
+ this.addTerm(data);
+ addedTerms[this.usedTerms.length - 1] = data;
+ }
+ } else {
+ this.addTerm(termData);
+ addedTerms[this.usedTerms.length - 1] = termData;
+ }
+
+ this.clearPartialTerm(this.input);
+
+ return addedTerms;
+ }
+
+ insertTerm(termData, termIndex) {
+ this.reIndexTerms(termIndex, 1, true);
+ this.registerTerm(termData, termIndex);
+ return this.insertRenderedTerm(this.renderTerm(termData, termIndex));
+ }
+
+ insertRenderedTerm(label) {
+ let next = this.termContainer.querySelector(`[data-index="${ label.dataset.index + 1 }"]`);
+ this.termContainer.insertBefore(label, next);
+ return label;
+ }
+
+ addTerm(termData, termIndex = null) {
+ if (termIndex === null) {
+ termIndex = this.registerTerm(termData);
+ }
+
+ this.addRenderedTerm(this.renderTerm(termData, termIndex));
+ }
+
+ addRenderedTerm(label) {
+ this.termContainer.appendChild(label);
+ }
+
+ hasTerms() {
+ return this.usedTerms.length > 0;
+ }
+
+ hasSyntaxError(input) {
+ if (typeof input === 'undefined') {
+ input = this.input;
+ }
+
+ return 'hasSyntaxError' in input.dataset;
+ }
+
+ clearSyntaxError(input) {
+ if (typeof input === 'undefined') {
+ input = this.input;
+ }
+
+ delete input.dataset.hasSyntaxError;
+ input.removeAttribute('pattern');
+ input.removeAttribute('title');
+ }
+
+ getQueryString() {
+ return this.termsToQueryString(this.usedTerms);
+ }
+
+ checkValidity(input) {
+ if (input.pattern && ! input.checkValidity()) {
+ if (! input.value.match(input.pattern)) {
+ if (input.dataset.invalidMsg) {
+ input.setCustomValidity(input.dataset.invalidMsg);
+ }
+
+ return false;
+ }
+
+ // If the pattern matches, reset the custom validity, otherwise the value is still invalid.
+ input.setCustomValidity('');
+ }
+
+ // The pattern isn't set or it matches. Any other custom validity must not be accounted for here.
+ return true;
+ }
+
+ reportValidity(element) {
+ setTimeout(() => element.reportValidity(), 0);
+ }
+
+ validate(element) {
+ if (! this.checkValidity(element)) {
+ this.reportValidity(element);
+
+ return false;
+ }
+
+ return true;
+ }
+
+ saveTerm(input, updateDOM = true, force = false) {
+ if (! this.checkValidity(input)) {
+ return false;
+ }
+
+ let termIndex = input.parentNode.dataset.index;
+ let termData = this.readFullTerm(input, termIndex);
+
+ // Only save if something has changed, unless forced
+ if (termData === false) {
+ console.warn('[BaseInput] Input is empty, cannot save');
+ } else if (force || this.usedTerms[termIndex].label !== termData.label) {
+ let oldTermData = this.usedTerms[termIndex];
+ this.usedTerms[termIndex] = termData;
+ this.updateTermData(termData, input);
+
+ return oldTermData;
+ }
+
+ return false;
+ }
+
+ updateTermData(termData, input) {
+ let label = input.parentNode;
+ label.dataset.label = termData.label;
+
+ if (!! termData.search || termData.search === '') {
+ label.dataset.search = termData.search;
+ }
+
+ if (!! termData.title) {
+ label.title = termData.title;
+ } else {
+ label.title = '';
+ }
+
+ if (termData.pattern) {
+ input.pattern = termData.pattern;
+ delete termData.pattern;
+
+ if (termData.invalidMsg) {
+ input.dataset.invalidMsg = termData.invalidMsg;
+ delete termData.invalidMsg;
+ }
+
+ this.validate(input);
+ }
+ }
+
+ termsToQueryString(terms) {
+ return terms.map(e => this.encodeTerm(e).search).join(this.separator).trim();
+ }
+
+ lastTerm() {
+ if (! this.hasTerms()) {
+ return null;
+ }
+
+ return this.usedTerms[this.usedTerms.length - 1];
+ }
+
+ popTerm() {
+ let lastTermIndex = this.usedTerms.length - 1;
+ return this.removeTerm(this.termContainer.querySelector(`[data-index="${ lastTermIndex }"]`));
+ }
+
+ removeTerm(label, updateDOM = true) {
+ if (this.completer !== null) {
+ this.completer.reset();
+ }
+
+ let termIndex = Number(label.dataset.index);
+
+ // Re-index following remaining terms
+ this.reIndexTerms(termIndex);
+
+ // Cut the term's data
+ let [termData] = this.usedTerms.splice(termIndex, 1);
+
+ // Avoid saving the term, it's removed after all
+ label.firstChild.skipSaveOnBlur = true;
+
+ if (updateDOM) {
+ // Remove it from the DOM
+ this.removeRenderedTerm(label);
+ }
+
+ return termData;
+ }
+
+ removeRenderedTerm(label) {
+ label.remove();
+ }
+
+ removeRange(labels) {
+ let from = Number(labels[0].dataset.index);
+ let to = Number(labels[labels.length - 1].dataset.index);
+ let deleteCount = to - from + 1;
+
+ if (to < this.usedTerms.length - 1) {
+ // Only re-index if there's something left
+ this.reIndexTerms(to, deleteCount);
+ }
+
+ let removedData = this.usedTerms.splice(from, deleteCount);
+
+ this.removeRenderedRange(labels);
+
+ let removedTerms = {};
+ for (let i = from; removedData.length; i++) {
+ removedTerms[i] = removedData.shift();
+ }
+
+ return removedTerms;
+ }
+
+ removeRenderedRange(labels) {
+ labels.forEach(label => this.removeRenderedTerm(label));
+ }
+
+ reIndexTerms(from, howMuch = 1, forward = false) {
+ if (forward) {
+ for (let i = this.usedTerms.length - 1; i >= from; i--) {
+ let label = this.termContainer.querySelector(`[data-index="${ i }"]`);
+ label.dataset.index = `${ i + howMuch }`;
+ }
+ } else {
+ for (let i = ++from; i < this.usedTerms.length; i++) {
+ let label = this.termContainer.querySelector(`[data-index="${ i }"]`);
+ label.dataset.index = `${ i - howMuch }`;
+ }
+ }
+ }
+
+ complete(input, data) {
+ if (this.completer !== null) {
+ $(input).trigger('complete', data);
+ }
+ }
+
+ selectTerms() {
+ this.termContainer.querySelectorAll('[data-index]').forEach(el => el.classList.add('selected'));
+ }
+
+ deselectTerms() {
+ this.termContainer.querySelectorAll('.selected').forEach(el => el.classList.remove('selected'));
+ }
+
+ clearSelectedTerms() {
+ if (this.hasTerms()) {
+ let labels = this.termContainer.querySelectorAll('.selected');
+ if (labels.length) {
+ return this.removeRange(Array.from(labels));
+ }
+ }
+
+ return {};
+ }
+
+ togglePlaceholder() {
+ if (this.isTermDirectionVertical()) {
+ return;
+ }
+
+ let placeholder = '';
+
+ if (! this.hasTerms()) {
+ if (this.input.dataset.placeholder) {
+ placeholder = this.input.dataset.placeholder;
+ } else {
+ return;
+ }
+ } else if (this.input.placeholder) {
+ if (! this.input.dataset.placeholder) {
+ this.input.dataset.placeholder = this.input.placeholder;
+ }
+ }
+
+ this.input.placeholder = placeholder;
+ }
+
+ renderTerm(termData, termIndex) {
+ let label = $.render('<label><input type="text"></label>');
+
+ if (termData.class) {
+ label.classList.add(termData.class);
+ }
+
+ if (termData.title) {
+ label.title = termData.title;
+ }
+
+ label.dataset.label = termData.label;
+ label.dataset.search = termData.search;
+ label.dataset.index = termIndex;
+
+ label.firstChild.value = termData.label;
+
+ return label;
+ }
+
+ encodeTerm(termData) {
+ termData = { ...termData };
+ termData.search = encodeURIComponent(termData.search);
+
+ return termData;
+ }
+
+ decodeTerm(termData) {
+ termData.search = decodeURIComponent(termData.search);
+
+ return termData;
+ }
+
+ shouldNotAutoSubmit() {
+ return 'noAutoSubmit' in this.input.dataset;
+ }
+
+ shouldNotAutoSubmitOnRemove() {
+ return 'noAutoSubmitOnRemove' in this.input.dataset;
+ }
+
+ autoSubmit(input, changeType, data) {
+ if (this.shouldNotAutoSubmit() || (changeType === 'remove' && this.shouldNotAutoSubmitOnRemove())) {
+ return;
+ }
+
+ if (changeType === 'save' && 'terms' in data) {
+ // Replace old term data with the new one, as required by the backend
+ for (const termIndex of Object.keys(data['terms'])) {
+ data['terms'][termIndex] = this.usedTerms[termIndex];
+ }
+ }
+
+ if (changeType === 'remove' && ! Object.keys(data['terms']).length) {
+ return;
+ }
+
+ this.dataInput.value = JSON.stringify({
+ type: changeType,
+ ...data
+ });
+
+ let eventData = { submittedBy: input };
+ if (changeType === 'paste') {
+ // Ensure that what's pasted is also transmitted as value
+ eventData['terms'] = this.termsToQueryString(data['terms']) + this.separator + data['input'];
+ }
+
+ $(this.input.form).trigger('submit', eventData);
+ }
+
+ submitTerms(terms) {
+ $(this.input.form).trigger(
+ 'submit',
+ { terms: terms }
+ );
+ }
+
+ isTermDirectionVertical() {
+ return this.input.dataset.termDirection === 'vertical';
+ }
+
+ moveFocusForward(from = null) {
+ let toFocus;
+
+ let inputs = Array.from(this.termContainer.querySelectorAll('input'));
+ if (from === null) {
+ let focused = this.termContainer.querySelector('input:focus');
+ from = inputs.indexOf(focused);
+ }
+
+ if (from === -1) {
+ toFocus = inputs.shift();
+ if (typeof toFocus === 'undefined') {
+ toFocus = this.input;
+ }
+ } else if (from + 1 < inputs.length) {
+ toFocus = inputs[from + 1];
+ } else {
+ toFocus = this.input;
+ }
+
+ toFocus.selectionStart = toFocus.selectionEnd = 0;
+ $(toFocus).focus();
+
+ return toFocus;
+ }
+
+ moveFocusBackward(from = null) {
+ let toFocus;
+
+ let inputs = Array.from(this.termContainer.querySelectorAll('input'));
+ if (from === null) {
+ let focused = this.termContainer.querySelector('input:focus');
+ from = inputs.indexOf(focused);
+ }
+
+ if (from === -1) {
+ toFocus = inputs.pop();
+ } else if (from > 0 && from - 1 < inputs.length) {
+ toFocus = inputs[from - 1];
+ } else {
+ toFocus = this.input;
+ }
+
+ toFocus.selectionStart = toFocus.selectionEnd = toFocus.value.length;
+ $(toFocus).focus();
+
+ return toFocus;
+ }
+
+ /**
+ * Event listeners
+ */
+
+ onSubmit(event) {
+ // Unset the input's name, to prevent its submission (It may actually have a name, as no-js fallback)
+ this.input.name = '';
+
+ // Set the hidden input's value, it's what's sent
+ if (event.detail && 'terms' in event.detail) {
+ this.termInput.value = event.detail.terms;
+ } else {
+ let renderedTerms = this.termsToQueryString(this.usedTerms);
+ if (this.hasSyntaxError()) {
+ renderedTerms += this.input.value;
+ }
+
+ this.termInput.value = renderedTerms;
+ }
+
+ // Enable the hidden input, otherwise it's not submitted
+ this.termInput.disabled = false;
+ }
+
+ onSuggestion(event) {
+ let data = event.detail;
+ let input = event.target;
+
+ let termData;
+ if (typeof data === 'object') {
+ termData = data;
+ } else {
+ termData = { label: data, search: data };
+ }
+
+ this.lastCompletedTerm = termData;
+ this.writePartialTerm(termData.label, input);
+ }
+
+ onCompletion(event) {
+ let input = event.target;
+ let termData = event.detail;
+ let termIndex = Number(input.parentNode.dataset.index);
+
+ this.lastCompletedTerm = termData;
+
+ if ('label' in termData) {
+ this.writePartialTerm(termData.label, input);
+ this.checkValidity(input);
+ }
+
+ if (termIndex >= 0) {
+ this.autoSubmit(input, 'save', { terms: { [termIndex]: this.saveTerm(input, false, true) } });
+ } else {
+ this.autoSubmit(input, 'exchange', { terms: this.exchangeTerm() });
+ this.togglePlaceholder();
+ }
+ }
+
+ onInput(event) {
+ let input = event.target;
+ let isTerm = input.parentNode.dataset.index >= 0;
+
+ let termData = { label: this.readPartialTerm(input) };
+ this.updateTermData(termData, input);
+
+ if (! input.value && this.hasSyntaxError(input)) {
+ this.clearSyntaxError(input);
+ }
+
+ if (! this.hasSyntaxError(input)) {
+ if (isTerm && ! this.validate(input)) {
+ return;
+ }
+
+ this.complete(input, { term: termData });
+ }
+
+ if (! isTerm) {
+ this.autoSubmit(this.input, 'remove', { terms: this.clearSelectedTerms() });
+ this.togglePlaceholder();
+ }
+ }
+
+ onKeyDown(event) {
+ let input = event.target;
+ let termIndex = Number(input.parentNode.dataset.index);
+
+ if (this.hasSyntaxError(input) && ! (/[A-Z]/.test(event.key.charAt(0)) || event.ctrlKey || event.metaKey)) {
+ // Clear syntax error flag if the user types entirely new input after having selected the entire input
+ // (This way the input isn't empty but switches from input to input immediately, causing the clearing
+ // in onInput to not work)
+ if (input.selectionEnd - input.selectionStart === input.value.length) {
+ this.clearSyntaxError(input);
+ }
+ }
+
+ let removedTerms;
+ switch (event.key) {
+ case ' ':
+ if (! this.readPartialTerm(input)) {
+ this.complete(input, { term: { label: '' } });
+ event.preventDefault();
+ }
+ break;
+ case 'Backspace':
+ removedTerms = this.clearSelectedTerms();
+
+ if (this.isTermDirectionVertical()) {
+ // pass
+ } else if (termIndex >= 0 && ! input.value) {
+ let removedTerm = this.removeTerm(input.parentNode);
+ if (removedTerm !== false) {
+ input = this.moveFocusBackward(termIndex);
+ if (event.ctrlKey || event.metaKey) {
+ this.clearPartialTerm(input);
+ } else {
+ this.writePartialTerm(input.value.slice(0, -1), input);
+ }
+
+ removedTerms[termIndex] = removedTerm;
+ event.preventDefault();
+ }
+ } else if (isNaN(termIndex)) {
+ if (! input.value && this.hasTerms()) {
+ let termData = this.popTerm();
+ if (! event.ctrlKey && ! event.metaKey) {
+ // Removing the last char programmatically is not
+ // necessary since the browser default is not prevented
+ this.writePartialTerm(termData.label, input);
+ }
+
+ removedTerms[this.usedTerms.length] = termData;
+ }
+ }
+
+ this.togglePlaceholder();
+ this.autoSubmit(input, 'remove', { terms: removedTerms });
+ break;
+ case 'Delete':
+ removedTerms = this.clearSelectedTerms();
+
+ if (! this.isTermDirectionVertical() && termIndex >= 0 && ! input.value) {
+ let removedTerm = this.removeTerm(input.parentNode);
+ if (removedTerm !== false) {
+ input = this.moveFocusForward(termIndex - 1);
+ if (event.ctrlKey || event.metaKey) {
+ this.clearPartialTerm(input);
+ } else {
+ this.writePartialTerm(input.value.slice(1), input);
+ }
+
+ removedTerms[termIndex] = removedTerm;
+ event.preventDefault();
+ }
+ }
+
+ this.togglePlaceholder();
+ this.autoSubmit(input, 'remove', { terms: removedTerms });
+ break;
+ case 'Enter':
+ if (termIndex >= 0) {
+ if (this.readPartialTerm(input)) {
+ this.saveTerm(input, false);
+ } else {
+ this.removeTerm(input.parentNode, false);
+ }
+ }
+ break;
+ case 'ArrowLeft':
+ if (input.selectionStart === 0 && this.hasTerms()) {
+ event.preventDefault();
+ this.moveFocusBackward();
+ }
+ break;
+ case 'ArrowRight':
+ if (input.selectionStart === input.value.length && this.hasTerms()) {
+ event.preventDefault();
+ this.moveFocusForward();
+ }
+ break;
+ case 'ArrowUp':
+ if (this.isTermDirectionVertical()
+ && input.selectionStart === 0
+ && this.hasTerms()
+ && (this.completer === null || ! this.completer.isBeingCompleted(input))
+ ) {
+ event.preventDefault();
+ this.moveFocusBackward();
+ }
+ break;
+ case 'ArrowDown':
+ if (this.isTermDirectionVertical()
+ && input.selectionStart === input.value.length
+ && this.hasTerms()
+ && (this.completer === null || ! this.completer.isBeingCompleted(input))
+ ) {
+ event.preventDefault();
+ this.moveFocusForward();
+ }
+ break;
+ case 'a':
+ if ((event.ctrlKey || event.metaKey) && ! this.readPartialTerm(input)) {
+ this.selectTerms();
+ }
+ }
+ }
+
+ onKeyUp(event) {
+ if (event.target.parentNode.dataset.index >= 0) {
+ return;
+ }
+
+ switch (event.key) {
+ case 'End':
+ case 'ArrowLeft':
+ case 'ArrowRight':
+ this.deselectTerms();
+ break;
+ case 'Home':
+ if (this.input.selectionStart === 0 && this.input.selectionEnd === 0) {
+ if (event.shiftKey) {
+ this.selectTerms();
+ } else {
+ this.deselectTerms();
+ }
+ }
+
+ break;
+ case 'Delete':
+ this.autoSubmit(event.target, 'remove', { terms: this.clearSelectedTerms() });
+ this.togglePlaceholder();
+ break;
+ }
+ }
+
+ onInputBlur() {
+ this.deselectTerms();
+ }
+
+ onTermFocusOut(event) {
+ let input = event.target;
+ if (this.hasSyntaxError(input)) {
+ return;
+ }
+
+ // skipSaveOnBlur is set if the input is about to be removed anyway.
+ // If we remove the input as well, the other removal will fail without
+ // any chance to handle it. (Element.remove() blurs the input)
+ if (typeof input.skipSaveOnBlur === 'undefined' || ! input.skipSaveOnBlur) {
+ setTimeout(() => {
+ if (this.completer === null || ! this.completer.isBeingCompleted(input)) {
+ let termIndex = Number(input.parentNode.dataset.index);
+ if (this.readPartialTerm(input)) {
+ let previousTerm = this.saveTerm(input);
+ if (previousTerm !== false) {
+ this.autoSubmit(input, 'save', { terms: { [termIndex]: previousTerm } });
+ }
+ } else {
+ this.autoSubmit(
+ input, 'remove', { terms: { [termIndex]: this.removeTerm(input.parentNode) } });
+ }
+ }
+ }, 0);
+ }
+ }
+
+ onTermFocus(event) {
+ let input = event.target;
+
+ if (input.parentNode.dataset.index >= 0) {
+ this.validate(input);
+ }
+
+ if (event.detail.scripted) {
+ // Only request suggestions if the user manually focuses the term
+ return;
+ }
+
+ this.deselectTerms();
+
+ if (! this.hasSyntaxError(input) && (
+ this.completer === null || ! this.completer.isBeingCompleted(input, false)
+ )) {
+ // Only request suggestions if the input is valid and not already being completed
+ let value = this.readPartialTerm(input);
+ this.complete(input, { trigger: 'script', term: { label: value } });
+ }
+ }
+
+ onButtonClick(event) {
+ if (! this.hasSyntaxError()) {
+ // Register current input value, otherwise it's not included
+ this.exchangeTerm();
+ }
+
+ if (this.hasTerms()) {
+ if (this.manageRequired) {
+ this.input.required = false;
+ }
+
+ // This is not part of `onSubmit()` because otherwise it would override what `autoSubmit()` does
+ this.dataInput.value = JSON.stringify({ type: 'submit', terms: this.usedTerms });
+
+ return;
+ } else if (this.manageRequired && ! this.hasTerms()) {
+ this.input.required = true;
+ }
+
+ this.dataInput.value = '';
+ }
+
+ onPaste(event) {
+ if (this.shouldNotAutoSubmit() || this.input.value) {
+ return;
+ }
+
+ this.autoSubmit(this.input, 'paste', {
+ input: event.clipboardData.getData('text/plain'),
+ terms: this.usedTerms
+ });
+
+ event.preventDefault();
+ }
+
+ onCopyAndCut(event) {
+ if (! this.hasTerms()) {
+ return;
+ }
+
+ let data = '';
+
+ let selectedTerms = this.termContainer.querySelectorAll('.selected');
+ if (selectedTerms.length) {
+ data = Array.from(selectedTerms).map(label => label.dataset.search).join(this.separator);
+ }
+
+ if (this.input.selectionStart < this.input.selectionEnd) {
+ data += this.separator + this.input.value.slice(this.input.selectionStart, this.input.selectionEnd);
+ }
+
+ event.clipboardData.setData('text/plain', data);
+ event.preventDefault();
+
+ if (event.type === 'cut') {
+ this.clearPartialTerm(this.input);
+ this.autoSubmit(this.input, 'remove', { terms: this.clearSelectedTerms() });
+ this.togglePlaceholder();
+ }
+ }
+ }
+
+ return BaseInput;
+});
diff --git a/asset/js/widget/Completer.js b/asset/js/widget/Completer.js
new file mode 100644
index 0000000..6d60380
--- /dev/null
+++ b/asset/js/widget/Completer.js
@@ -0,0 +1,750 @@
+define(["../notjQuery"], function ($) {
+
+ "use strict";
+
+ class Completer {
+ constructor(input, instrumented = false) {
+ this.input = input;
+ this.instrumented = instrumented;
+ this.selectionStartInput = null;
+ this.selectionActive = false;
+ this.mouseSelectionActive = false;
+ this.nextSuggestion = null;
+ this.activeSuggestion = null;
+ this.suggestionKiller = null;
+ this.completedInput = null;
+ this.completedValue = null;
+ this.completedData = null;
+ this._termSuggestions = null;
+ }
+
+ get termSuggestions() {
+ if (this._termSuggestions === null) {
+ this._termSuggestions = document.querySelector(this.input.dataset.termSuggestions);
+ }
+
+ return this._termSuggestions;
+ }
+
+ bind(to = null) {
+ // Form submissions
+ $(this.input.form).on('submit', this.onSubmit, this);
+
+ // User interactions
+ $(this.termSuggestions).on('focusout', '[type="button"]', this.onFocusOut, this);
+ $(this.termSuggestions).on('click', '[type="button"]', this.onSuggestionClick, this);
+ $(this.termSuggestions).on('keydown', '[type="button"]', this.onSuggestionKeyDown, this);
+
+ if (this.selectionEnabled()) {
+ $(this.termSuggestions).on('keyup', '[type="button"]', this.onSuggestionKeyUp, this);
+ $(this.termSuggestions).on('mouseover', '[type="button"]', this.onSuggestionMouseOver, this);
+ $(this.termSuggestions).on('mousedown', '[type="button"]', this.onSuggestionMouseDown, this);
+ $(this.termSuggestions).on('mouseup', '[type="button"]', this.onSuggestionsMouseUp, this);
+ $(this.termSuggestions).on('mouseleave', this.onSuggestionsMouseLeave, this);
+ }
+
+ if (this.instrumented) {
+ if (to !== null) {
+ $(to).on('focusout', 'input[type="text"]', this.onFocusOut, this);
+ $(to).on('keydown', 'input[type="text"]', this.onKeyDown, this);
+ $(to).on('complete', 'input[type="text"]', this.onComplete, this);
+ }
+
+ $(this.input).on('complete', this.onComplete, this);
+ } else {
+ $(this.input).on('input', this.onInput, this);
+ }
+
+ $(this.input).on('focusout', this.onFocusOut, this);
+ $(this.input).on('keydown', this.onKeyDown, this);
+
+ return this;
+ }
+
+ refresh(input, bindTo = null) {
+ if (input === this.input) {
+ // If the DOM node is still the same, nothing has changed
+ return;
+ }
+
+ this._termSuggestions = null;
+ this.abort();
+
+ this.input = input;
+ this.bind(bindTo);
+ }
+
+ reset() {
+ this.abort();
+ this.hideSuggestions();
+ }
+
+ destroy() {
+ this._termSuggestions = null;
+ this.input = null;
+ }
+
+ renderSuggestions(html) {
+ let template = document.createElement('template');
+ template.innerHTML = html;
+
+ return template.content;
+ }
+
+ showSuggestions(suggestions, input) {
+ this.termSuggestions.innerHTML = '';
+ this.termSuggestions.appendChild(suggestions);
+ this.termSuggestions.style.display = '';
+
+ let containingBlock = this.termSuggestions.offsetParent || document.body;
+ let containingBlockRect = containingBlock.getBoundingClientRect();
+ let inputRect = input.getBoundingClientRect();
+ let inputPosX = inputRect.left - containingBlockRect.left;
+ let inputPosY = inputRect.bottom - containingBlockRect.top;
+ let suggestionWidth = this.termSuggestions.offsetWidth;
+
+ let maxAvailableHeight = document.body.clientHeight - inputRect.bottom;
+ let localMarginBottom = window.getComputedStyle(this.termSuggestions).marginBottom;
+
+ this.termSuggestions.style.top = `${ inputPosY }px`;
+ this.termSuggestions.style.maxHeight = `calc(${maxAvailableHeight}px - ${localMarginBottom})`;
+ if (inputPosX + suggestionWidth > containingBlockRect.right - containingBlockRect.left) {
+ this.termSuggestions.style.left =
+ `${ containingBlockRect.right - containingBlockRect.left - suggestionWidth }px`;
+ } else {
+ this.termSuggestions.style.left = `${ inputPosX }px`;
+ }
+ }
+
+ hasSuggestions() {
+ return this.termSuggestions.childNodes.length > 0;
+ }
+
+ hideSuggestions() {
+ if (this.nextSuggestion !== null || this.activeSuggestion !== null) {
+ return;
+ }
+
+ if (this.suggestionKiller !== null) {
+ // onFocusOut initiates this timer in order to hide the suggestions if the user
+ // doesn't navigate them. Since it does this by checking after a short interval
+ // if the focus is inside the suggestions, the interval has to be long enough to
+ // have a chance to detect the focus. `focusout` runs before `blur` and `focus`,
+ // so this may lead to a race condition which is addressed by the timeout. Though,
+ // to not close the newly opened suggestions of the next input the timer has to
+ // be cancelled here since it's purpose is already fulfilled.
+ clearTimeout(this.suggestionKiller);
+ this.suggestionKiller = null;
+ }
+
+ this.termSuggestions.style.display = 'none';
+ this.termSuggestions.innerHTML = '';
+
+ this.completedInput = null;
+ this.completedValue = null;
+ this.completedData = null;
+
+ this.endSelection();
+ }
+
+ prepareCompletionData(input, data = null) {
+ if (data === null) {
+ data = { term: { ...input.dataset } };
+ data.term.label = input.value;
+ }
+
+ let value = data.term.label;
+ data.term.search = value;
+ data.term.label = this.addWildcards(value);
+
+ if (input.parentElement instanceof HTMLFieldSetElement) {
+ for (let element of input.parentElement.elements) {
+ if (element !== input
+ && element.name !== input.name + '-search'
+ && (element.name.substr(-7) === '-search'
+ || typeof input.form[element.name + '-search'] === 'undefined')
+ ) {
+ // Make sure we'll use a key that the server can understand..
+ let dataName = element.name;
+ if (dataName.substr(-7) === '-search') {
+ dataName = dataName.substr(0, dataName.length - 7);
+ }
+ if (dataName.substr(0, input.parentElement.name.length) === input.parentElement.name) {
+ dataName = dataName.substr(input.parentElement.name.length);
+ }
+
+ if (! dataName in data || element.value) {
+ data[dataName] = element.value;
+ }
+ }
+ }
+ }
+
+ return [value, data];
+ }
+
+ addWildcards(value) {
+ if (! value) {
+ return '*';
+ }
+
+ if (value.slice(0, 1) !== '*' && value.slice(-1) !== '*') {
+ return '*' + value + '*';
+ }
+
+ return value;
+ }
+
+ abort() {
+ if (this.activeSuggestion !== null) {
+ this.activeSuggestion.abort();
+ this.activeSuggestion = null;
+ }
+
+ if (this.nextSuggestion !== null) {
+ clearTimeout(this.nextSuggestion);
+ this.nextSuggestion = null;
+ }
+ }
+
+ requestCompletion(input, data, trigger = 'user') {
+ this.abort();
+
+ this.nextSuggestion = setTimeout(() => {
+ let req = new XMLHttpRequest();
+ req.open('POST', this.input.dataset.suggestUrl, true);
+ req.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
+ req.setRequestHeader('Content-Type', 'application/json');
+
+ if (typeof icinga !== 'undefined') {
+ let windowId = icinga.ui.getWindowId();
+ let containerId = icinga.ui.getUniqueContainerId(this.termSuggestions);
+ if (containerId) {
+ req.setRequestHeader('X-Icinga-WindowId', windowId + '_' + containerId);
+ } else {
+ req.setRequestHeader('X-Icinga-WindowId', windowId);
+ }
+ }
+
+ req.addEventListener('loadend', () => {
+ if (req.readyState > 0) {
+ if (req.responseText) {
+ let suggestions = this.renderSuggestions(req.responseText);
+ if (trigger === 'script') {
+ // If the suggestions are to be displayed due to a scripted event,
+ // show them only if the completed input is still focused..
+ if (document.activeElement === input) {
+ this.showSuggestions(suggestions, input);
+ }
+ } else {
+ this.showSuggestions(suggestions, input);
+ }
+ } else {
+ this.hideSuggestions();
+ }
+ }
+
+ this.activeSuggestion = null;
+ this.nextSuggestion = null;
+ });
+
+ req.send(JSON.stringify(data));
+
+ this.activeSuggestion = req;
+ }, 200);
+ }
+
+ suggest(input, value, data = {}) {
+ if (this.instrumented) {
+ if (! Object.keys(data).length) {
+ data = value;
+ }
+
+ $(input).trigger('suggestion', data);
+ } else {
+ input.value = value;
+ }
+ }
+
+ complete(input, value, data) {
+ $(input).focus({ scripted: true });
+
+ if (this.instrumented) {
+ if (! Object.keys(data).length) {
+ data = value;
+ }
+
+ $(input).trigger('completion', data);
+ } else {
+ input.value = value;
+
+ for (let name in data) {
+ let dataElement = input.form[input.name + '-' + name];
+ if (typeof dataElement !== 'undefined') {
+ if (dataElement instanceof RadioNodeList) {
+ dataElement = dataElement[dataElement.length - 1];
+ }
+
+ dataElement.value = data[name];
+ } else if (name === 'title') {
+ input.title = data[name];
+ }
+ }
+ }
+
+ this.hideSuggestions();
+ }
+
+ moveToSuggestion(backwards = false, stopAtEdge = false) {
+ let focused = this.termSuggestions.querySelector('[type="button"]:focus');
+ let inputs = Array.from(this.termSuggestions.querySelectorAll('[type="button"]'));
+
+ let input;
+ if (focused !== null) {
+ let sibling = inputs[backwards ? inputs.indexOf(focused) - 1 : inputs.indexOf(focused) + 1];
+ if (sibling) {
+ input = sibling;
+ } else if (stopAtEdge) {
+ return null;
+ } else {
+ input = this.completedInput;
+ }
+ } else {
+ input = inputs[backwards ? inputs.length - 1 : 0];
+ }
+
+ $(input).focus();
+
+ if (! stopAtEdge && this.completedValue !== null) {
+ if (input === this.completedInput) {
+ this.suggest(this.completedInput, this.completedValue);
+ } else {
+ this.suggest(this.completedInput, input.value, { ...input.dataset });
+ }
+ }
+
+ return input;
+ }
+
+ isBeingCompleted(input, activeElement = null) {
+ if (activeElement === null) {
+ activeElement = document.activeElement;
+ }
+
+ return input === this.completedInput && this.hasSuggestions()
+ && (! activeElement || input === activeElement || this.termSuggestions.contains(activeElement));
+ }
+
+ selectionEnabled() {
+ return this.instrumented && 'withMultiCompletion' in this.input.dataset;
+ }
+
+ selectionAllowed() {
+ return this.completedInput === this.input && this.selectionEnabled();
+ }
+
+ startSelection(input) {
+ this.selectionActive = true;
+ this.selectionStartInput = input;
+ }
+
+ isSelectionActive() {
+ return this.selectionActive;
+ }
+
+ endSelection() {
+ this.selectionStartInput = null;
+ this.selectionActive = false;
+ this.mouseSelectionActive = false;
+ }
+
+ selectSuggestion(input) {
+ input.classList.add('selected');
+ }
+
+ deselectSuggestion(input) {
+ input.classList.remove('selected');
+ }
+
+ toggleSelection(input) {
+ input.classList.toggle('selected');
+ let selected = input.classList.contains('selected');
+ if (selected && ! this.isSelectionActive()) {
+ this.startSelection(input);
+ $(input).focus();
+ }
+
+ if (! selected && input === this.selectionStartInput) {
+ this.selectionStartInput = this.termSuggestions.querySelector('[type="button"].selected');
+ if (! this.selectionStartInput) {
+ this.endSelection();
+ $(this.input).focus();
+ } else {
+ $(this.selectionStartInput).focus();
+ }
+ }
+
+ return selected;
+ }
+
+ isSelectedSuggestion(input) {
+ return input.classList.contains('selected');
+ }
+
+ getSelectedSuggestions() {
+ return this.termSuggestions.querySelectorAll('[type="button"].selected');
+ }
+
+ clearSelection() {
+ if (! this.isSelectionActive()) {
+ return;
+ }
+
+ for (const selectedInput of this.getSelectedSuggestions()) {
+ this.deselectSuggestion(selectedInput);
+ }
+
+ this.endSelection();
+ }
+
+ handleKeySelection(input, newInput) {
+ if (! this.isSelectionActive()) {
+ this.startSelection(input);
+ this.selectSuggestion(input);
+ this.selectSuggestion(newInput);
+ this.suggest(this.completedInput, '');
+ } else if (this.isSelectedSuggestion(newInput)) {
+ this.deselectSuggestion(input);
+ } else {
+ this.selectSuggestion(newInput);
+ }
+ }
+
+ startMouseSelection(input) {
+ this.startSelection(input);
+ this.mouseSelectionActive = true;
+ }
+
+ isMouseSelectionActive() {
+ return this.mouseSelectionActive;
+ }
+
+ finishMouseSelection() {
+ if (! this.mouseSelectionActive) {
+ return;
+ }
+
+ this.mouseSelectionActive = false;
+ this.selectSuggestion(this.selectionStartInput);
+
+ let selectionFound = false;
+ let selectionCandidates = [];
+ for (const input of this.termSuggestions.querySelectorAll('[type="button"]')) {
+ if (input.classList.contains('selected')) {
+ if (selectionFound) {
+ for (const candidate of selectionCandidates) {
+ this.selectSuggestion(candidate);
+ }
+
+ selectionCandidates = [];
+ } else {
+ selectionFound = true;
+ }
+ } else if (selectionFound) {
+ selectionCandidates.push(input);
+ }
+ }
+ }
+
+ /**
+ * Event listeners
+ */
+
+ onSubmit(event) {
+ if (! event.detail || ! event.detail.submittedBy) {
+ // Reset all states, the user is about to navigate away
+ this.reset();
+ }
+ }
+
+ onFocusOut(event) {
+ if (this.completedInput === null) {
+ // If there are multiple instances of Completer bound to the same suggestion container
+ // all of them try to handle the event. Though, only one of them is responsible and
+ // that's the one which has a completed input set.
+ return;
+ }
+
+ let input = event.target;
+ let completedInput = this.completedInput;
+ this.suggestionKiller = setTimeout(() => {
+ if (completedInput !== this.completedInput) {
+ // Don't hide another input's suggestions
+ } else if (document.activeElement !== completedInput
+ && ! this.termSuggestions.contains(document.activeElement)
+ ) {
+ // Hide the suggestions if the user doesn't navigate them
+ if (input !== completedInput) {
+ // Restore input if a suggestion lost focus
+ this.suggest(completedInput, this.completedValue);
+ }
+
+ this.hideSuggestions();
+ }
+ }, 250);
+ }
+
+ onSuggestionMouseDown(event) {
+ if (! this.selectionAllowed()) {
+ return;
+ }
+
+ if (event.ctrlKey || event.metaKey) {
+ // onSuggestionClick only toggles the suggestion's selection and should
+ // be the only one who decides which other suggestion should be focused
+ event.preventDefault();
+ } else {
+ this.clearSelection();
+ this.startMouseSelection(event.target);
+ }
+ }
+
+ onSuggestionsMouseUp(event) {
+ if (! event.ctrlKey && ! event.metaKey) {
+ this.finishMouseSelection();
+ }
+ }
+
+ onSuggestionsMouseLeave(_) {
+ this.finishMouseSelection();
+ }
+
+ onSuggestionMouseOver(event) {
+ if (this.isMouseSelectionActive()) {
+ this.selectSuggestion(event.target);
+ }
+ }
+
+ onSuggestionKeyUp(event) {
+ if (this.completedInput === null) {
+ return;
+ }
+
+ let input = event.target;
+
+ switch (event.key) {
+ case 'Shift':
+ if (this.isSelectionActive()) {
+ event.preventDefault();
+
+ if (input === this.selectionStartInput && this.getSelectedSuggestions().length === 1) {
+ this.deselectSuggestion(input);
+ this.endSelection();
+ }
+ }
+
+ break;
+ }
+ }
+
+ onSuggestionKeyDown(event) {
+ if (this.completedInput === null) {
+ return;
+ }
+
+ let newInput;
+ let input = event.target;
+ let allowSelection = event.shiftKey && this.selectionAllowed();
+
+ switch (event.key) {
+ case 'Escape':
+ $(this.completedInput).focus({ scripted: true });
+ this.suggest(this.completedInput, this.completedValue);
+ this.clearSelection();
+ break;
+ case 'Tab':
+ event.preventDefault();
+ this.moveToSuggestion(event.shiftKey);
+ break;
+ case 'ArrowLeft':
+ case 'ArrowUp':
+ event.preventDefault();
+
+ newInput = this.moveToSuggestion(true, allowSelection);
+ if (allowSelection) {
+ if (newInput !== null) {
+ this.handleKeySelection(input, newInput);
+ }
+ } else {
+ this.clearSelection();
+ }
+
+ break;
+ case 'ArrowRight':
+ case 'ArrowDown':
+ event.preventDefault();
+
+ newInput = this.moveToSuggestion(false, allowSelection);
+ if (allowSelection) {
+ if (newInput !== null) {
+ this.handleKeySelection(input, newInput);
+ }
+ } else {
+ this.clearSelection();
+ }
+
+ break;
+ }
+ }
+
+ onSuggestionClick(event) {
+ if (this.completedInput === null) {
+ return;
+ }
+
+ if (event.ctrlKey || event.metaKey) {
+ if (this.selectionAllowed()) {
+ this.toggleSelection(event.target);
+ event.preventDefault();
+ }
+ } else if (this.isSelectionActive() && this.isSelectedSuggestion(event.target)) {
+ let terms = [];
+ for (const suggestion of this.getSelectedSuggestions()) {
+ terms.push({ ...suggestion.dataset });
+ }
+
+ this.complete(this.completedInput, null, { type: 'terms', terms: terms });
+ } else {
+ let input = event.currentTarget;
+
+ this.complete(this.completedInput, input.value, { ...input.dataset });
+ }
+ }
+
+ onKeyDown(event) {
+ let suggestions;
+
+ switch (event.key) {
+ case ' ':
+ if (this.instrumented) {
+ break;
+ }
+
+ let input = event.target;
+
+ if (! input.value) {
+ if (input.minLength <= 0) {
+ let [value, data] = this.prepareCompletionData(input);
+ this.completedInput = input;
+ this.completedValue = value;
+ this.completedData = data;
+ this.requestCompletion(input, data);
+ }
+
+ event.preventDefault();
+ }
+
+ break;
+ case 'Tab':
+ suggestions = this.termSuggestions.querySelectorAll('[type="button"]');
+ if (suggestions.length === 1) {
+ event.preventDefault();
+ let input = event.target;
+ let suggestion = suggestions[0];
+
+ this.complete(input, suggestion.value, { ...suggestion.dataset });
+ }
+
+ break;
+ case 'Enter':
+ let defaultSuggestion = this.termSuggestions.querySelector('.default > [type="button"]');
+ if (defaultSuggestion !== null) {
+ event.preventDefault();
+ let input = event.target;
+
+ this.complete(input, defaultSuggestion.value, { ...defaultSuggestion.dataset });
+ }
+
+ break;
+ case 'Escape':
+ if (this.hasSuggestions()) {
+ this.hideSuggestions()
+ event.preventDefault();
+ }
+
+ break;
+ case 'ArrowUp':
+ suggestions = this.termSuggestions.querySelectorAll('[type="button"]');
+ if (suggestions.length) {
+ event.preventDefault();
+ this.moveToSuggestion(true);
+ }
+
+ break;
+ case 'ArrowDown':
+ suggestions = this.termSuggestions.querySelectorAll('[type="button"]');
+ if (suggestions.length) {
+ event.preventDefault();
+ this.moveToSuggestion();
+ }
+
+ break;
+ default:
+ if (/[A-Z]/.test(event.key.charAt(0)) || event.key === '"') {
+ // Ignore control keys not resulting in new input data
+ break;
+ }
+
+ let typedSuggestion = this.termSuggestions.querySelector(`[value="${ event.key }"]`);
+ if (typedSuggestion !== null) {
+ this.hideSuggestions();
+ }
+ }
+ }
+
+ onInput(event) {
+ let input = event.target;
+
+ if (input.minLength > 0 && input.value.length < input.minLength) {
+ return;
+ }
+
+ // Set the input's value as search value. This ensures that if the user doesn't
+ // choose a suggestion, an up2date contextual value will be transmitted with
+ // completion requests and the server can properly identify a new value upon submit
+ input.dataset.search = input.value;
+ if (typeof input.form[input.name + '-search'] !== 'undefined') {
+ let dataElement = input.form[input.name + '-search'];
+ if (dataElement instanceof RadioNodeList) {
+ dataElement = dataElement[dataElement.length - 1];
+ }
+
+ dataElement.value = input.value;
+ }
+
+ let [value, data] = this.prepareCompletionData(input);
+ this.completedInput = input;
+ this.completedValue = value;
+ this.completedData = data;
+ this.requestCompletion(input, data);
+ }
+
+ onComplete(event) {
+ let input = event.target;
+ let { trigger = 'user' , ...detail } = event.detail;
+
+ let [value, data] = this.prepareCompletionData(input, detail);
+ this.completedInput = input;
+ this.completedValue = value;
+ this.completedData = data;
+
+ if (typeof data.suggestions !== 'undefined') {
+ this.showSuggestions(data.suggestions, input);
+ } else {
+ this.requestCompletion(input, data, trigger);
+ }
+ }
+ }
+
+ return Completer;
+});
diff --git a/asset/js/widget/CopyToClipboard.js b/asset/js/widget/CopyToClipboard.js
new file mode 100644
index 0000000..e3b348c
--- /dev/null
+++ b/asset/js/widget/CopyToClipboard.js
@@ -0,0 +1,49 @@
+define(["../notjQuery"], function ($) {
+
+ "use strict";
+
+ class CopyToClipboard {
+ constructor(button)
+ {
+ button.classList.add('active');
+ button.removeAttribute('tabindex');
+ $(button).on('click', null, this.onClick, this);
+ }
+
+ onClick(event)
+ {
+ let button = event.currentTarget;
+ let clipboardSource = button.parentElement.querySelector("[data-clipboard-source]");
+ let copyText;
+
+ if (clipboardSource) {
+ copyText = clipboardSource.innerText;
+ } else {
+ throw new Error('Clipboard source is required but not provided');
+ }
+
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(copyText).then(() => {
+ let previousHtml = button.innerHTML;
+ button.innerText = button.dataset.copiedLabel;
+ button.classList.add('copied');
+
+ setTimeout(() => {
+ // after 4 second, reset it.
+ button.classList.remove('copied');
+ button.innerHTML = previousHtml;
+ }, 4000);
+ }).catch((err) => {
+ console.error('Failed to copy: ', err);
+ });
+ } else {
+ throw new Error('Copy to clipboard requires HTTPS connection');
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+ }
+ }
+
+ return CopyToClipboard;
+});
diff --git a/asset/js/widget/FilterInput.js b/asset/js/widget/FilterInput.js
new file mode 100644
index 0000000..fad3da0
--- /dev/null
+++ b/asset/js/widget/FilterInput.js
@@ -0,0 +1,1521 @@
+define(["../notjQuery", "BaseInput"], function ($, BaseInput) {
+
+ "use strict";
+
+ class FilterInput extends BaseInput {
+ constructor(input) {
+ super(input);
+
+ this.termType = 'column';
+
+ /**
+ * The negation operator
+ *
+ * @type {{}}
+ */
+ this.negationOperator = { label: '!', search: '!', class: 'logical_operator', type: 'negation_operator' };
+
+ /**
+ * Supported grouping operators
+ *
+ * @type {{close: {}, open: {}}}
+ */
+ this.grouping_operators = {
+ open: { label: '(', search: '(', class: 'grouping_operator_open', type: 'grouping_operator' },
+ close: { label: ')', search: ')', class: 'grouping_operator_close', type: 'grouping_operator' }
+ };
+
+ /**
+ * Supported logical operators
+ *
+ * The first is also the default.
+ *
+ * @type {{}[]}
+ */
+ this.logical_operators = [
+ { label: '&', search: '&', class: 'logical_operator', type: 'logical_operator', default: true },
+ { label: '|', search: '|', class: 'logical_operator', type: 'logical_operator' },
+ ];
+
+ /**
+ * Supported relational operators
+ *
+ * The first is also the default.
+ *
+ * @type {{}[]}
+ */
+ this.relational_operators = [
+ { label: '~', search: '~', class: 'operator', type: 'operator', default: true },
+ { label: '!~', search: '!~', class: 'operator', type: 'operator' },
+ { label: '=', search: '=', class: 'operator', type: 'operator' },
+ { label: '!=', search: '!=', class: 'operator', type: 'operator' },
+ { label: '>', search: '>', class: 'operator', type: 'operator' },
+ { label: '<', search: '<', class: 'operator', type: 'operator' },
+ { label: '>=', search: '>=', class: 'operator', type: 'operator' },
+ { label: '<=', search: '<=', class: 'operator', type: 'operator' }
+ ];
+ }
+
+ bind() {
+ $(this.termContainer).on('click', '[data-group-type="condition"] > button', this.onRemoveCondition, this);
+ $(this.termContainer).on('click', '[data-index]', this.onTermClick, this);
+ $(this.termContainer).on('mouseover', '[data-index]', this.onTermHover, this);
+ $(this.termContainer).on('mouseout', '[data-index]', this.onTermLeave, this);
+ return super.bind();
+ }
+
+ reset() {
+ super.reset();
+
+ this.termType = 'column';
+ }
+
+ restoreTerms() {
+ if (super.restoreTerms()) {
+ this.reportValidity(this.input.form);
+ return true;
+ }
+
+ return false;
+ }
+
+ registerTerms() {
+ super.registerTerms();
+
+ if (this.hasTerms()) {
+ this.termType = this.nextTermType(this.lastTerm());
+ }
+ }
+
+ registerTerm(termData, termIndex = null) {
+ termIndex = super.registerTerm(termData, termIndex);
+
+ if (termData.type === 'grouping_operator' && typeof termData.counterpart === 'undefined') {
+ let counterpart;
+ if (this.isGroupOpen(termData)) {
+ counterpart = this.nextPendingGroupClose(termIndex);
+ } else {
+ counterpart = this.lastPendingGroupOpen(termIndex);
+ }
+
+ if (counterpart !== null) {
+ termData.counterpart = counterpart;
+ this.usedTerms[counterpart].counterpart = termIndex;
+ }
+ }
+
+ return termIndex;
+ }
+
+ readFullTerm(input, termIndex = null) {
+ let termData = super.readFullTerm(input, termIndex);
+ if (termData === false) {
+ return false;
+ }
+
+ if (! Array.isArray(termData) && ! termData.type) {
+ termData.type = this.termType;
+ }
+
+ return termData;
+ }
+
+ insertTerm(termData, termIndex) {
+ let label = super.insertTerm(termData, termIndex);
+
+ if (termIndex === this.usedTerms.length - 1) {
+ this.termType = this.nextTermType(termData);
+ } else {
+ let next = this.termContainer.querySelector(`[data-index="${ termIndex + 1 }"]`);
+ this.checkValidity(next.firstChild, next.dataset.type, termIndex + 1);
+ }
+
+ return label;
+ }
+
+ insertRenderedTerm(label) {
+ let termIndex = Number(label.dataset.index);
+ if (label.dataset.counterpart >= 0) {
+ let otherLabel = this.termContainer.querySelector(`[data-index="${ label.dataset.counterpart }"]`);
+ if (otherLabel !== null) {
+ otherLabel.dataset.counterpart = termIndex;
+ this.checkValidity(otherLabel.firstChild);
+ }
+ }
+
+ let previous = this.termContainer.querySelector(`[data-index="${ termIndex - 1 }"]`);
+ switch (label.dataset.type) {
+ case 'column':
+ let newCondition = this.renderCondition();
+ newCondition.appendChild(label);
+
+ if (previous) {
+ previous.parentNode.insertBefore(newCondition, previous.nextSibling);
+ } else {
+ this.termContainer.insertBefore(newCondition, this.termContainer.firstChild);
+ }
+
+ break;
+ case 'operator':
+ case 'value':
+ previous.parentNode.appendChild(label);
+ break;
+ case 'logical_operator':
+ if (previous) {
+ if (previous.parentNode.dataset.groupType === 'condition') {
+ previous.parentNode.parentNode.insertBefore(label, previous.parentNode.nextSibling);
+ } else {
+ previous.parentNode.insertBefore(label, previous.nextSibling);
+ }
+ } else {
+ this.termContainer.insertBefore(label, this.termContainer.firstChild);
+ }
+
+ break;
+ case 'negation_operator':
+ if (previous) {
+ previous.parentNode.insertBefore(label, previous.nextSibling);
+ } else {
+ this.termContainer.insertBefore(label, this.termContainer.firstChild);
+ }
+
+ break;
+ case 'grouping_operator':
+ if (this.isGroupOpen(label.dataset)) {
+ if (label.dataset.counterpart >= 0) {
+ let counterpart = this.termContainer.querySelector(
+ `[data-index="${ label.dataset.counterpart }"]`
+ );
+ counterpart.parentNode.insertBefore(label, counterpart.parentNode.firstChild);
+ } else {
+ let newGroup = this.renderChain();
+ newGroup.appendChild(label);
+
+ let sibling = previous ? previous.nextSibling : this.termContainer.firstChild;
+ while (sibling !== null && sibling.dataset.type !== 'grouping_operator') {
+ let nextSibling = sibling.nextSibling;
+ newGroup.appendChild(sibling);
+ sibling = nextSibling;
+ }
+
+ if (previous) {
+ previous.parentNode.insertBefore(newGroup, previous.nextSibling);
+ } else {
+ // newGroup should be now the only child then
+ this.termContainer.appendChild(newGroup);
+ }
+ }
+ } else {
+ let chain = this.termContainer.querySelector(
+ `[data-index="${ label.dataset.counterpart }"]`
+ ).parentNode;
+ if (previous.parentNode.dataset.groupType && previous.parentNode !== chain) {
+ previous = previous.parentNode;
+ }
+
+ if (previous.parentNode !== chain) {
+ // The op is being moved by the user again, after it was already moved
+ let sibling = previous;
+ let lastSibling = null;
+ while (sibling !== null && sibling !== chain) {
+ let previousSibling = sibling.previousSibling;
+ chain.insertBefore(sibling, lastSibling);
+ lastSibling = sibling;
+ sibling = previousSibling;
+ }
+ }
+
+ // There may be terms following in the same level which now should be a level above
+ let sibling = previous.nextSibling;
+ let refNode = chain.nextSibling;
+ while (sibling !== null) {
+ let nextSibling = sibling.nextSibling;
+ chain.parentNode.insertBefore(sibling, refNode);
+ sibling = nextSibling;
+ }
+
+ chain.appendChild(label);
+ }
+ }
+
+ if (termIndex === this.usedTerms.length - 1) {
+ this.identifyLastRenderedTerm();
+ }
+
+ return label;
+ }
+
+ addTerm(termData, termIndex = null) {
+ super.addTerm(termData, termIndex);
+
+ if (termData.counterpart >= 0) {
+ let otherLabel = this.termContainer.querySelector(`[data-index="${ termData.counterpart }"]`);
+ if (otherLabel !== null) {
+ otherLabel.dataset.counterpart = termIndex || this.usedTerms[termData.counterpart].counterpart;
+ this.checkValidity(otherLabel.firstChild);
+ }
+ }
+
+ this.termType = this.nextTermType(termData);
+ }
+
+ addRenderedTerm(label) {
+ let newGroup = null;
+ let leaveGroup = false;
+ let currentGroup = null;
+
+ switch (label.dataset.type) {
+ case 'column':
+ newGroup = this.renderCondition();
+ break;
+ case 'grouping_operator':
+ if (this.isGroupOpen(label.dataset)) {
+ newGroup = this.renderChain();
+ } else {
+ let termIndex = Number(label.dataset.index);
+ let previous = this.termContainer.querySelector(`[data-index="${ termIndex - 1 }"]`);
+
+ currentGroup = this.termContainer.querySelector(
+ `[data-index="${ label.dataset.counterpart }"]`
+ ).parentNode;
+ if (previous.parentNode.dataset.groupType && previous.parentNode !== currentGroup) {
+ previous = previous.parentNode;
+ }
+
+ if (previous.parentNode !== currentGroup) {
+ // The op is being moved by the user again, after it was already moved
+ let sibling = previous;
+ let lastSibling = null;
+ while (sibling !== null && sibling !== currentGroup) {
+ let previousSibling = sibling.previousSibling;
+ currentGroup.insertBefore(sibling, lastSibling);
+ lastSibling = sibling;
+ sibling = previousSibling;
+ }
+ }
+ }
+
+ break;
+ case 'logical_operator':
+ currentGroup = this.currentGroup;
+ leaveGroup = currentGroup.dataset.groupType === 'condition';
+ }
+
+ if (currentGroup === null) {
+ currentGroup = this.currentGroup;
+ }
+
+ if (newGroup !== null) {
+ newGroup.appendChild(label);
+ currentGroup.appendChild(newGroup);
+ } else if (leaveGroup) {
+ currentGroup.parentNode.appendChild(label);
+ } else {
+ currentGroup.appendChild(label);
+ }
+
+ this.identifyLastRenderedTerm();
+ }
+
+ identifyLastRenderedTerm() {
+ let lastTerm = Array.from(this.termContainer.querySelectorAll('[data-index]')).pop();
+ if (! lastTerm) {
+ return;
+ }
+
+ let lastLabel = this.termContainer.querySelector('.last-term');
+ if (lastLabel !== null) {
+ if (lastLabel === lastTerm) {
+ return;
+ }
+
+ lastLabel.classList.remove('last-term');
+ }
+
+ lastTerm.classList.add('last-term');
+ }
+
+ termsToQueryString(terms) {
+ if (! this.input.form.checkValidity()) {
+ let filtered = [];
+ for (let i = 0; i < terms.length; i++) {
+ const input = this.termContainer.querySelector(`[data-index="${ i }"] > input`);
+ if (input === null || this.isGroupOpen(terms[i]) || input.checkValidity()) {
+ filtered.push(terms[i]);
+ } else if (input) {
+ // Ignore all terms after an invalid one
+ break;
+ }
+ }
+
+ terms = filtered;
+ }
+
+ return super.termsToQueryString(terms);
+ }
+
+ removeTerm(label, updateDOM = true) {
+ let termIndex = Number(label.dataset.index);
+ if (termIndex < this.usedTerms.length - 1) {
+ // It's not the last term
+ if (! this.validate(label.firstChild)) {
+ return false;
+ }
+ }
+
+ let termData = super.removeTerm(label, updateDOM);
+
+ if (this.hasTerms()) {
+ if (termIndex === this.usedTerms.length) {
+ // It's been the last term
+ this.termType = this.nextTermType(this.lastTerm());
+ }
+
+ if (termData.counterpart >= 0) {
+ let otherLabel = this.termContainer.querySelector(`[data-index="${ termData.counterpart }"]`);
+ delete this.usedTerms[otherLabel.dataset.index].counterpart;
+ delete otherLabel.dataset.counterpart;
+ this.checkValidity(otherLabel.firstChild);
+ }
+ } else {
+ this.termType = 'column';
+ }
+
+ return termData;
+ }
+
+ removeRange(labels) {
+ let removedTerms = super.removeRange(labels);
+
+ if (this.hasTerms()) {
+ this.termType = this.nextTermType(this.lastTerm());
+
+ labels.forEach((label) => {
+ if (label.dataset.counterpart >= 0) {
+ let otherLabel = this.termContainer.querySelector(
+ `[data-counterpart="${ label.dataset.index }"]`
+ );
+ if (otherLabel !== null) {
+ delete this.usedTerms[otherLabel.dataset.index].counterpart;
+ delete otherLabel.dataset.counterpart;
+ this.checkValidity(otherLabel.firstChild);
+ }
+ }
+ });
+ } else {
+ this.termType = 'column';
+ }
+
+ return removedTerms;
+ }
+
+ removeRenderedTerm(label) {
+ let parent = label.parentNode;
+ let children = parent.querySelectorAll(':scope > [data-index], :scope > [data-group-type]');
+ if (parent.dataset.groupType && children.length === 1) {
+ // If the parent is a group and the label is the only child, we can remove the entire group
+ parent.remove();
+ } else {
+ super.removeRenderedTerm(label);
+
+ if (parent.dataset.groupType === 'chain') {
+ // Get a new nodes list first, otherwise the removed label is still part of it
+ children = parent.querySelectorAll(':scope > [data-index], :scope > [data-group-type]');
+ let hasNoGroupOperators = children[0].dataset.type !== 'grouping_operator'
+ && children[children.length - 1].dataset.type !== 'grouping_operator';
+ if (hasNoGroupOperators) {
+ // Unwrap remaining terms, remove the resulting empty group
+ Array.from(children).forEach(child => parent.parentNode.insertBefore(child, parent));
+ parent.remove();
+ }
+ }
+ }
+
+ if (Number(label.dataset.index) >= this.usedTerms.length - 1) {
+ this.identifyLastRenderedTerm();
+ }
+ }
+
+ removeRenderedRange(labels) {
+ let to = Number(labels[labels.length - 1].dataset.index);
+
+ while (labels.length) {
+ let label = labels.shift();
+ let parent = label.parentNode;
+ if (parent.dataset.groupType && label === parent.firstChild) {
+ let counterpartIndex = Number(label.dataset.counterpart);
+ if (isNaN(counterpartIndex)) {
+ counterpartIndex = Number(
+ Array.from(parent.querySelectorAll(':scope > [data-index]')).pop().dataset.index
+ );
+ }
+
+ if (counterpartIndex <= to) {
+ // If the parent's terms are all to be removed, we'll remove the
+ // entire parent to keep the DOM operations as efficient as possible
+ parent.remove();
+
+ labels.splice(0, counterpartIndex - Number(label.dataset.index));
+ continue;
+ }
+ }
+
+ this.removeRenderedTerm(label);
+ }
+ }
+
+ reIndexTerms(from, howMuch = 1, forward = false) {
+ let fromLabel = this.termContainer.querySelector(`[data-index="${ from }"]`);
+
+ super.reIndexTerms(from, howMuch, forward);
+
+ let _this = this;
+ this.termContainer.querySelectorAll('[data-counterpart]').forEach(label => {
+ let counterpartIndex = Number(label.dataset.counterpart);
+ if ((forward && counterpartIndex >= from) || (! forward && counterpartIndex > from)) {
+ counterpartIndex += forward ? howMuch : -howMuch;
+
+ let termIndex = Number(label.dataset.index);
+ if (
+ (! forward && termIndex > from - howMuch && label !== fromLabel)
+ || (forward && termIndex >= from)
+ ) {
+ // Make sure to use the previous index to access usedTerms, it's not adjusted yet
+ termIndex += forward ? -howMuch : howMuch;
+ }
+
+ label.dataset.counterpart = `${ counterpartIndex }`;
+ _this.usedTerms[termIndex].counterpart = `${ counterpartIndex }`;
+ }
+ });
+ }
+
+ complete(input, data) {
+ let termIndex = Number(input.parentNode.dataset.index);
+ if (termIndex >= 0) {
+ data.term.type = this.usedTerms[termIndex].type;
+ } else {
+ termIndex = this.usedTerms.length;
+ data.term.type = this.termType;
+ }
+
+ // Special cases
+ switch (data.term.type) {
+ case 'grouping_operator':
+ case 'negation_operator':
+ return;
+ case 'column':
+ data.showQuickSearch = termIndex === this.usedTerms.length;
+ break;
+ case 'value':
+ let terms = [ ...this.usedTerms ];
+ terms.splice(termIndex - 2, 3, { type: 'column', search: '' },
+ { type: 'operator', search: '' }, { type: 'value', search: '' });
+
+ data.searchFilter = this.termsToQueryString(terms);
+ break;
+ case 'operator':
+ case 'logical_operator':
+ let suggestions = this.validOperator(
+ data.trigger === 'script' ? '' : data.term.label, data.term.type, termIndex);
+ if (suggestions.exactMatch && ! suggestions.partialMatches) {
+ // User typed a suggestion manually, don't show the same suggestion again
+ return;
+ }
+
+ data.suggestions = this.renderSuggestions(suggestions);
+ }
+
+ // Additional metadata
+ switch (data.term.type) {
+ case 'value':
+ data.operator = this.usedTerms[--termIndex].search;
+ case 'operator':
+ data.column = this.usedTerms[--termIndex].search;
+ }
+
+ super.complete(input, data);
+ }
+
+ nextTermType(termData) {
+ switch (termData.type) {
+ case 'column':
+ return 'operator';
+ case 'operator':
+ return 'value';
+ case 'value':
+ return 'logical_operator';
+ case 'logical_operator':
+ case 'negation_operator':
+ return 'column';
+ case 'grouping_operator':
+ return this.isGroupOpen(termData) ? 'column' : 'logical_operator';
+ }
+ }
+
+ get currentGroup() {
+ let label = Array.from(this.termContainer.querySelectorAll('[data-index]')).pop();
+ if (! label) {
+ return this.termContainer;
+ }
+
+ let termData = this.usedTerms[label.dataset.index];
+ switch (termData.type) {
+ case 'grouping_operator':
+ if (this.isGroupOpen(termData)) {
+ break;
+ }
+ case 'value':
+ return label.parentNode.parentNode;
+ }
+
+ return label.parentNode;
+ }
+
+ lastPendingGroupOpen(before) {
+ let level = 0;
+ for (let i = before - 1; i >= 0 && i < this.usedTerms.length; i--) {
+ let termData = this.usedTerms[i];
+
+ if (termData.type === 'grouping_operator') {
+ if (this.isGroupOpen(termData)) {
+ if (level === 0) {
+ return typeof termData.counterpart === 'undefined' ? i : null;
+ }
+
+ level++;
+ } else {
+ if (termData.counterpart >= 0) {
+ i = termData.counterpart;
+ } else {
+ level--;
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+
+ nextPendingGroupClose(after) {
+ let level = 0;
+ for (let i = after + 1; i < this.usedTerms.length; i++) {
+ let termData = this.usedTerms[i];
+
+ if (termData.type === 'grouping_operator') {
+ if (this.isGroupClose(termData)) {
+ if (level === 0) {
+ return typeof termData.counterpart === 'undefined' ? i : null;
+ }
+
+ level--;
+ } else {
+ if (termData.counterpart >= 0) {
+ i = termData.counterpart;
+ } else {
+ level++;
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+
+ isGroupOpen(termData) {
+ return termData.type === 'grouping_operator' && termData.search === this.grouping_operators.open.search;
+ }
+
+ isGroupClose(termData) {
+ return termData.type === 'grouping_operator' && termData.search === this.grouping_operators.close.search;
+ }
+
+ getOperator(value, termType = null) {
+ if (termType === null) {
+ termType = this.termType;
+ }
+
+ let operators;
+ switch (termType) {
+ case 'operator':
+ operators = this.relational_operators;
+ break;
+ case 'logical_operator':
+ operators = this.logical_operators;
+ break;
+ }
+
+ value = value.toLowerCase();
+ return operators.find((term) => {
+ return value === term.label.toLowerCase() || value === term.search.toLowerCase();
+ }) || null;
+ }
+
+ matchOperators(operators, value) {
+ value = value.toLowerCase();
+
+ let exactMatch = false;
+ let partialMatch = false;
+ let filtered = operators.filter((op) => {
+ let label = op.label.toLowerCase();
+ let search = op.search.toLowerCase();
+
+ if (
+ (value.length < label.length && value === label.slice(0, value.length))
+ || (value.length < search.length && value === search.slice(0, value.length))
+ ) {
+ partialMatch = true;
+ return true;
+ }
+
+ if (value === label || value === search) {
+ exactMatch = true;
+ return true;
+ }
+
+ return false;
+ });
+
+ if (exactMatch || partialMatch) {
+ operators = filtered;
+ }
+
+ operators.exactMatch = exactMatch;
+ operators.partialMatches = partialMatch;
+
+ return operators;
+ }
+
+ nextOperator(value, currentValue, termType = null, termIndex = null) {
+ let operators = [];
+
+ if (termType === null) {
+ termType = this.termType;
+ }
+
+ if (termIndex === null && termType === 'column' && ! currentValue) {
+ switch (true) {
+ case ! this.hasTerms():
+ case this.lastTerm().type === 'logical_operator':
+ case this.isGroupOpen(this.lastTerm()):
+ operators.push(this.grouping_operators.open);
+ operators.push(this.negationOperator);
+ }
+ } else if (termIndex === -1) {
+ // This is more of a `previousOperator` thing here
+ switch (termType) {
+ case 'column':
+ operators = operators.concat(this.logical_operators);
+ case 'logical_operator':
+ operators.push(this.grouping_operators.open);
+ operators.push(this.negationOperator);
+ break;
+ case 'negation_operator':
+ operators = operators.concat(this.logical_operators);
+ operators.push(this.grouping_operators.open);
+ break;
+ case 'grouping_operator':
+ if (this.isGroupOpen(this.usedTerms[0])) {
+ operators.push(this.grouping_operators.open);
+ operators.push(this.negationOperator);
+ }
+ }
+ } else {
+ let nextIndex = termIndex === null ? this.usedTerms.length : termIndex + 1;
+ switch (termType) {
+ case 'column':
+ operators = operators.concat(this.relational_operators);
+
+ if (! currentValue || (termIndex !== null && termIndex < this.usedTerms.length)) {
+ operators.push(this.grouping_operators.open);
+ operators.push(this.negationOperator);
+ }
+ case 'operator':
+ case 'value':
+ operators = operators.concat(this.logical_operators);
+
+ if (this.lastPendingGroupOpen(nextIndex) !== null) {
+ operators.push(this.grouping_operators.close);
+ }
+
+ break;
+ case 'logical_operator':
+ if (this.lastPendingGroupOpen(nextIndex) !== null) {
+ operators.push(this.grouping_operators.close);
+ }
+
+ if (termIndex !== null && termIndex < this.usedTerms.length) {
+ operators.push(this.grouping_operators.open);
+ operators.push(this.negationOperator);
+ }
+
+ break;
+ case 'negation_operator':
+ operators.push(this.grouping_operators.open);
+
+ break;
+ case 'grouping_operator':
+ let termData = this.usedTerms[termIndex];
+ if (this.isGroupOpen(termData)) {
+ operators.push(this.grouping_operators.open);
+ operators.push(this.negationOperator);
+ } else {
+ operators = operators.concat(this.logical_operators);
+
+ if (this.lastPendingGroupOpen(nextIndex)) {
+ operators.push(this.grouping_operators.close);
+ }
+ }
+ }
+ }
+
+ return value ? this.matchOperators(operators, value) : operators;
+ }
+
+ validOperator(value, termType = null, termIndex = null) {
+ let operators = [];
+
+ if (termType === null) {
+ termType = this.termType;
+ }
+
+ switch (termType) {
+ case 'operator':
+ operators = operators.concat(this.relational_operators);
+ break;
+ case 'logical_operator':
+ operators = operators.concat(this.logical_operators);
+ break;
+ case 'negation_operator':
+ operators.push(this.negationOperator);
+ break;
+ case 'grouping_operator':
+ let termData = this.usedTerms[termIndex];
+ if (termData.counterpart >= 0) {
+ let counterpart = this.usedTerms[termData.counterpart];
+ if (this.isGroupOpen(counterpart)) {
+ operators.push(this.grouping_operators.close);
+ } else {
+ operators.push(this.grouping_operators.open);
+ }
+ }
+ }
+
+ return value ? this.matchOperators(operators, value) : operators;
+ }
+
+ checkValidity(input, type = null, termIndex = null) {
+ if (! super.checkValidity(input)) {
+ return false;
+ }
+
+ if (type === null) {
+ type = input.parentNode.dataset.type;
+ }
+
+ if (! type || type === 'value') {
+ // type is undefined for the main input, values have no special validity rules
+ return true;
+ }
+
+ if (termIndex === null && input.parentNode.dataset.index >= 0) {
+ termIndex = Number(input.parentNode.dataset.index);
+ }
+
+ let value = this.readPartialTerm(input);
+
+ let options;
+ switch (type) {
+ case 'operator':
+ case 'logical_operator':
+ case 'negation_operator':
+ case 'grouping_operator':
+ options = this.validOperator(value, type, termIndex);
+ }
+
+ let message = '';
+ if (type === 'column') {
+ let nextTermAt = termIndex + 1;
+ if (! value && nextTermAt < this.usedTerms.length && this.usedTerms[nextTermAt].type === 'operator') {
+ message = this.input.dataset.chooseColumn;
+ }
+ } else {
+ let isRequired = ! options.exactMatch;
+ if (type === 'negation_operator' && ! value) {
+ isRequired = false;
+ } else if (type === 'operator' && ! value) {
+ let nextTermAt = termIndex + 1;
+ isRequired = nextTermAt < this.usedTerms.length && this.usedTerms[nextTermAt].type === 'value';
+ } else if (type === 'logical_operator' && ! value) {
+ if (termIndex === 0 || termIndex === this.usedTerms.length - 1) {
+ isRequired = false;
+ } else {
+ isRequired = ! this.isGroupOpen(this.usedTerms[termIndex - 1])
+ && ! this.isGroupClose(this.usedTerms[termIndex + 1])
+ && this.usedTerms[termIndex - 1].type !== 'logical_operator'
+ && this.usedTerms[termIndex + 1].type !== 'logical_operator';
+ }
+ } else if (type === 'grouping_operator') {
+ if (typeof this.usedTerms[termIndex].counterpart === 'undefined') {
+ if (value) {
+ message = this.input.dataset.incompleteGroup;
+ }
+
+ isRequired = false;
+ } else if (! value) {
+ isRequired = false;
+ }
+ }
+
+ if (isRequired) {
+ message = this.input.dataset.chooseTemplate.replace(
+ '%s',
+ options.map(e => e.label).join(', ')
+ );
+ }
+ }
+
+ if (! message && termIndex > 0 && type !== 'logical_operator') {
+ let previousTerm = this.usedTerms[termIndex - 1];
+
+ let missingLogicalOp = true;
+ switch (type) {
+ case 'column':
+ missingLogicalOp = ! ['logical_operator', 'negation_operator'].includes(previousTerm.type)
+ && ! this.isGroupOpen(previousTerm);
+ break;
+ case 'operator':
+ missingLogicalOp = previousTerm.type !== 'column';
+ break;
+ case 'value':
+ missingLogicalOp = previousTerm.type !== 'operator';
+ break;
+ case 'negation_operator':
+ missingLogicalOp = previousTerm.type !== 'logical_operator'
+ && ! this.isGroupOpen(previousTerm);
+ break;
+ case 'grouping_operator':
+ if (value === this.grouping_operators.open.label) {
+ missingLogicalOp = ! ['logical_operator', 'negation_operator'].includes(previousTerm.type)
+ && ! this.isGroupOpen(previousTerm);
+ } else {
+ missingLogicalOp = false;
+ }
+ }
+
+ if (missingLogicalOp) {
+ message = this.input.dataset.missingLogOp;
+ }
+ }
+
+ input.setCustomValidity(message);
+ return input.checkValidity();
+ }
+
+ renderSuggestions(suggestions) {
+ let itemTemplate = $.render('<li><input type="button" tabindex="-1"></li>');
+
+ let list = document.createElement('ul');
+
+ suggestions.forEach((term) => {
+ let item = itemTemplate.cloneNode(true);
+ item.firstChild.value = term.label;
+
+ for (let name in term) {
+ if (name === 'default') {
+ if (term[name]) {
+ item.classList.add('default');
+ }
+ } else {
+ item.firstChild.dataset[name] = term[name];
+ }
+ }
+
+ list.appendChild(item);
+ });
+
+ return list;
+ }
+
+ renderPreview(content) {
+ return $.render('<span>' + content + '</span>');
+ }
+
+ renderCondition() {
+ return $.render(
+ '<div class="filter-condition" data-group-type="condition">'
+ + '<button type="button"><i class="icon fa fa-trash"></i></button>'
+ + '</div>'
+ );
+ }
+
+ renderChain() {
+ return $.render('<div class="filter-chain" data-group-type="chain"></div>');
+ }
+
+ renderTerm(termData, termIndex) {
+ let label = super.renderTerm(termData, termIndex);
+ label.dataset.type = termData.type;
+
+ if (! termData.class) {
+ label.classList.add(termData.type);
+ }
+
+ if (termData.counterpart >= 0) {
+ label.dataset.counterpart = termData.counterpart;
+ }
+
+ return label;
+ }
+
+ autoSubmit(input, changeType, data) {
+ if (this.shouldNotAutoSubmit()) {
+ return;
+ }
+
+ let changedTerms = [];
+ if ('terms' in data) {
+ changedTerms = data['terms'];
+ }
+
+ let changedIndices = Object.keys(changedTerms).sort((a, b) => a - b);
+ if (! changedIndices.length) {
+ return;
+ }
+
+ let lastTermAt;
+ switch (changeType) {
+ case 'add':
+ case 'exchange':
+ lastTermAt = changedIndices.pop();
+ if (changedTerms[lastTermAt].type === 'value') {
+ if (! changedIndices.length) {
+ data['terms'] = {
+ ...{
+ [lastTermAt - 2]: this.usedTerms[lastTermAt - 2],
+ [lastTermAt - 1]: this.usedTerms[lastTermAt - 1]
+ },
+ ...changedTerms
+ };
+ }
+
+ break;
+ } else if (this.isGroupClose(changedTerms[lastTermAt])) {
+ break;
+ }
+
+ return;
+ case 'insert':
+ lastTermAt = changedIndices.pop();
+ if ((changedTerms[lastTermAt].type === 'value' && changedIndices.length)
+ || this.isGroupClose(changedTerms[lastTermAt])
+ || (changedTerms[lastTermAt].type === 'negation_operator'
+ && lastTermAt < this.usedTerms.length - 1
+ )
+ ) {
+ break;
+ }
+
+ return;
+ case 'save':
+ let updateAt = changedIndices[0];
+ let valueAt = updateAt;
+ switch (changedTerms[updateAt].type) {
+ case 'column':
+ if (changedTerms[updateAt].label !== this.usedTerms[updateAt].label) {
+ return;
+ }
+
+ valueAt++;
+ case 'operator':
+ valueAt++;
+ }
+
+ if (valueAt === updateAt) {
+ if (changedIndices.length === 1) {
+ data['terms'] = {
+ ...{
+ [valueAt - 2]: this.usedTerms[valueAt - 2],
+ [valueAt - 1]: this.usedTerms[valueAt - 1]
+ },
+ ...changedTerms
+ };
+ }
+
+ break;
+ } else if (this.usedTerms.length > valueAt && this.usedTerms[valueAt].type === 'value') {
+ break;
+ }
+
+ return;
+ case 'remove':
+ let firstTermAt = changedIndices.shift();
+ if (changedTerms[firstTermAt].type === 'column'
+ || this.isGroupOpen(changedTerms[firstTermAt])
+ || changedTerms[firstTermAt].type === 'negation_operator'
+ || (changedTerms[firstTermAt].type === 'logical_operator' && changedIndices.length)
+ ) {
+ break;
+ }
+
+ return;
+ }
+
+ super.autoSubmit(input, changeType, data);
+ }
+
+ encodeTerm(termData) {
+ if (termData.type === 'column' || termData.type === 'value') {
+ termData = super.encodeTerm(termData);
+ termData.search = termData.search.replace(
+ /[()]/g,
+ function(c) {
+ return '%' + c.charCodeAt(0).toString(16);
+ }
+ );
+ }
+
+ return termData;
+ }
+
+ isTermDirectionVertical() {
+ return false;
+ }
+
+ highlightTerm(label, highlightedBy = null) {
+ label.classList.add('highlighted');
+
+ let canBeHighlighted = (label) => ! ('highlightedBy' in label.dataset)
+ && label.firstChild !== document.activeElement
+ && (this.completer === null
+ || ! this.completer.isBeingCompleted(label.firstChild)
+ );
+
+ if (highlightedBy !== null) {
+ if (canBeHighlighted(label)) {
+ label.dataset.highlightedBy = highlightedBy;
+ }
+ } else {
+ highlightedBy = label.dataset.index;
+ }
+
+ let negationAt, previousIndex, nextIndex;
+ switch (label.dataset.type) {
+ case 'column':
+ case 'operator':
+ case 'value':
+ label.parentNode.querySelectorAll(':scope > [data-index]').forEach((otherLabel) => {
+ if (otherLabel !== label && canBeHighlighted(otherLabel)) {
+ otherLabel.classList.add('highlighted');
+ otherLabel.dataset.highlightedBy = highlightedBy;
+ }
+ });
+
+ negationAt = Number(label.dataset.index) - (
+ label.dataset.type === 'column'
+ ? 1 : label.dataset.type === 'operator'
+ ? 2 : 3
+ );
+ if (negationAt >= 0 && this.usedTerms[negationAt].type === 'negation_operator') {
+ let negationLabel = this.termContainer.querySelector(`[data-index="${ negationAt }"]`);
+ if (negationLabel !== null && canBeHighlighted(negationLabel)) {
+ negationLabel.classList.add('highlighted');
+ negationLabel.dataset.highlightedBy = highlightedBy;
+ }
+ }
+
+ break;
+ case 'logical_operator':
+ previousIndex = Number(label.dataset.index) - 1;
+ if (previousIndex >= 0 && this.usedTerms[previousIndex].type !== 'logical_operator') {
+ this.highlightTerm(
+ this.termContainer.querySelector(`[data-index="${ previousIndex }"]`),
+ highlightedBy
+ );
+ }
+
+ nextIndex = Number(label.dataset.index) + 1;
+ if (nextIndex < this.usedTerms.length && this.usedTerms[nextIndex].type !== 'logical_operator') {
+ this.highlightTerm(
+ this.termContainer.querySelector(`[data-index="${ nextIndex }"]`),
+ highlightedBy
+ );
+ }
+
+ break;
+ case 'negation_operator':
+ nextIndex = Number(label.dataset.index) + 1;
+ if (nextIndex < this.usedTerms.length) {
+ this.highlightTerm(
+ this.termContainer.querySelector(`[data-index="${ nextIndex }"]`),
+ highlightedBy
+ );
+ }
+
+ break;
+ case 'grouping_operator':
+ negationAt = null;
+ if (this.isGroupOpen(label.dataset)) {
+ negationAt = Number(label.dataset.index) - 1;
+ }
+
+ if (label.dataset.counterpart >= 0) {
+ let otherLabel = this.termContainer.querySelector(
+ `[data-index="${ label.dataset.counterpart }"]`
+ );
+ if (otherLabel !== null) {
+ if (negationAt === null) {
+ negationAt = Number(otherLabel.dataset.index) - 1;
+ }
+
+ if (canBeHighlighted(otherLabel)) {
+ otherLabel.classList.add('highlighted');
+ otherLabel.dataset.highlightedBy = highlightedBy;
+ }
+ }
+ }
+
+ if (negationAt >= 0 && this.usedTerms[negationAt].type === 'negation_operator') {
+ let negationLabel = this.termContainer.querySelector(`[data-index="${ negationAt }"]`);
+ if (negationLabel !== null && canBeHighlighted(negationLabel)) {
+ negationLabel.classList.add('highlighted');
+ negationLabel.dataset.highlightedBy = highlightedBy;
+ }
+ }
+ }
+ }
+
+ deHighlightTerm(label) {
+ if (! ('highlightedBy' in label.dataset)) {
+ label.classList.remove('highlighted');
+ }
+
+ this.termContainer.querySelectorAll(`[data-highlighted-by="${ label.dataset.index }"]`).forEach(
+ (label) => {
+ label.classList.remove('highlighted');
+ delete label.dataset.highlightedBy;
+ }
+ );
+ }
+
+ /**
+ * Event listeners
+ */
+
+ onTermFocusOut(event) {
+ let label = event.currentTarget;
+ if (this.completer === null || ! this.completer.isBeingCompleted(label.firstChild, event.relatedTarget)) {
+ this.deHighlightTerm(label);
+ }
+
+ if (['column', 'value'].includes(label.dataset.type) || ! this.readPartialTerm(label.firstChild)) {
+ super.onTermFocusOut(event);
+ }
+ }
+
+ onTermFocus(event) {
+ let input = event.target;
+ let isTerm = input.parentNode.dataset.index >= 0;
+ let termType = input.parentNode.dataset.type || this.termType;
+
+ if (isTerm) {
+ this.highlightTerm(input.parentNode);
+ }
+
+ let value = this.readPartialTerm(input);
+ if (! value && (termType === 'column' || termType === 'value')) {
+ if (isTerm) {
+ this.validate(input);
+ }
+
+ // No automatic suggestions without input
+ return;
+ }
+
+ super.onTermFocus(event);
+ }
+
+ onTermClick(event) {
+ if (this.disabled) {
+ return;
+ }
+
+ let input = event.target;
+ let termType = input.parentNode.dataset.type;
+
+ if (['logical_operator', 'operator'].includes(termType)) {
+ this.complete(input, { trigger: 'script', term: { label: this.readPartialTerm(input) } });
+ }
+ }
+
+ onTermHover(event) {
+ if (this.disabled) {
+ return;
+ }
+
+ let label = event.currentTarget;
+
+ if (['column', 'operator', 'value'].includes(label.dataset.type)) {
+ // This adds a class to delay the remove button. If it's shown instantly upon hover
+ // it's too easy to accidentally click it instead of the desired grouping operator.
+ label.parentNode.classList.add('_hover_delay');
+ setTimeout(function () {
+ label.parentNode.classList.remove('_hover_delay');
+ }, 500);
+ }
+
+ this.highlightTerm(label);
+ }
+
+ onTermLeave(event) {
+ if (this.disabled) {
+ return;
+ }
+
+ let label = event.currentTarget;
+ if (label.firstChild !== document.activeElement
+ && (this.completer === null || ! this.completer.isBeingCompleted(label.firstChild))
+ ) {
+ this.deHighlightTerm(label);
+ }
+ }
+
+ onRemoveCondition(event) {
+ let button = event.target.closest('button');
+ let labels = Array.from(button.parentNode.querySelectorAll(':scope > [data-index]'));
+
+ let previous = button.parentNode.previousSibling;
+ let next = button.parentNode.nextSibling;
+
+ while (previous !== null || next !== null) {
+ if (previous !== null && previous.dataset.type === 'negation_operator') {
+ labels.unshift(previous);
+ previous = previous.previousSibling;
+ }
+
+ if (next !== null && next.dataset.type === 'logical_operator') {
+ labels.push(next);
+ next = next.nextSibling;
+ } else if (previous !== null && previous.dataset.type === 'logical_operator') {
+ labels.unshift(previous);
+ previous = previous.previousSibling;
+ }
+
+ if (
+ previous && previous.dataset.type === 'grouping_operator'
+ && next && next.dataset.type === 'grouping_operator'
+ ) {
+ labels.unshift(previous);
+ labels.push(next);
+ previous = next.parentNode !== null ? next.parentNode.previousSibling : null;
+ next = next.parentNode !== null ? next.parentNode.nextSibling : null;
+ } else {
+ break
+ }
+ }
+
+ this.autoSubmit(this.input, 'remove', { terms: this.removeRange(labels) });
+ this.togglePlaceholder();
+ }
+
+ onCompletion(event) {
+ super.onCompletion(event);
+
+ if (event.target.parentNode.dataset.index >= 0) {
+ return;
+ }
+
+ if (this.termType === 'operator' || this.termType === 'logical_operator') {
+ this.complete(this.input, { term: { label: '' } });
+ }
+ }
+
+ onKeyDown(event) {
+ super.onKeyDown(event);
+ if (event.defaultPrevented) {
+ return;
+ }
+
+ let input = event.target;
+ let isTerm = input.parentNode.dataset.index >= 0;
+
+ if (this.hasSyntaxError(input)) {
+ return;
+ }
+
+ let currentValue = this.readPartialTerm(input);
+ if (isTerm && ! currentValue) {
+ // Switching contexts requires input first
+ return;
+ } else if (input.selectionStart !== input.selectionEnd) {
+ // In case the user selected a range of text, do nothing
+ return;
+ } else if (/[A-Z]/.test(event.key.charAt(0)) || event.ctrlKey || event.metaKey) {
+ // Ignore control keys not resulting in new input data
+ // TODO: Remove this and move the entire block into `onInput`
+ // once Safari supports `InputEvent.data`
+ return;
+ }
+
+ let termIndex = null;
+ let termType = this.termType;
+ if (isTerm) {
+ if (input.selectionEnd === input.value.length) {
+ // Cursor is at the end of the input
+ termIndex = Number(input.parentNode.dataset.index);
+ termType = input.parentNode.dataset.type;
+ } else if (input.selectionStart === 0) {
+ // Cursor is at the start of the input
+ termIndex = Number(input.parentNode.dataset.index);
+ if (termIndex === 0) {
+ // TODO: This is bad, if it causes problems, replace it
+ // with a proper `previousOperator` implementation
+ termType = this.usedTerms[termIndex].type;
+ termIndex -= 1;
+ } else {
+ termIndex -= 1;
+ termType = this.usedTerms[termIndex].type;
+ }
+ } else {
+ // In case the cursor is somewhere in between, do nothing
+ return;
+ }
+
+ if (termIndex > -1 && termIndex < this.usedTerms.length - 1) {
+ let nextTerm = this.usedTerms[termIndex + 1];
+ if (nextTerm.type === 'operator' || nextTerm.type === 'value') {
+ // In between parts of a condition there's no context switch possible at all
+ return;
+ }
+ }
+ } else if (input.selectionEnd !== input.value.length) {
+ // Main input processing only happens at the end of the input
+ return;
+ }
+
+ let operators;
+ let value = event.key;
+ if (! isTerm || termType === 'operator') {
+ operators = this.validOperator(
+ termType === 'operator' ? currentValue + value : value, termType, termIndex);
+ if (! operators.exactMatch && ! operators.partialMatches) {
+ operators = this.nextOperator(value, currentValue, termType, termIndex);
+ }
+ } else {
+ operators = this.nextOperator(value, currentValue, termType, termIndex);
+ }
+
+ if (isTerm) {
+ let newTerm = null;
+ let exactMatchOnly = operators.exactMatch && ! operators.partialMatches;
+ if (exactMatchOnly && operators[0].label.toLowerCase() !== value.toLowerCase()) {
+ // The user completes a partial match
+ } else if (exactMatchOnly && (termType !== 'operator' || operators[0].type !== 'operator')) {
+ newTerm = { ...operators[0] };
+ } else if (operators.partialMatches && termType !== 'operator') {
+ newTerm = { ...operators[0], label: value, search: value };
+ } else {
+ // If no match is found, the user continues typing
+ switch (termType) {
+ case 'operator':
+ newTerm = { label: value, search: value, type: 'value' };
+ break;
+ case 'logical_operator':
+ case 'negation_operator':
+ newTerm = { label: value, search: value, type: 'column' };
+ break;
+ }
+ }
+
+ if (newTerm !== null) {
+ let label = this.insertTerm(newTerm, termIndex + 1);
+ this.autoSubmit(label.firstChild, 'insert', { terms: { [termIndex + 1]: newTerm } });
+ this.complete(label.firstChild, { term: newTerm });
+ $(label.firstChild).focus({ scripted: true });
+ event.preventDefault();
+ }
+ } else {
+ if (operators.partialMatches) {
+ this.exchangeTerm();
+ this.togglePlaceholder();
+ } else if (operators.exactMatch) {
+ if (termType !== operators[0].type) {
+ this.autoSubmit(input, 'exchange', { terms: this.exchangeTerm() });
+ } else {
+ this.clearPartialTerm(input);
+ }
+
+ this.addTerm({ ...operators[0] });
+ this.autoSubmit(input, 'add', { terms: { [this.usedTerms.length - 1]: operators[0] } });
+ this.togglePlaceholder();
+ event.preventDefault();
+ } else if (termType === 'operator') {
+ let partialOperator = this.getOperator(currentValue);
+ if (partialOperator !== null) {
+ // If no match is found, the user seems to want the partial operator.
+ this.addTerm({ ...partialOperator });
+ this.clearPartialTerm(input);
+ }
+ }
+ }
+ }
+
+ onInput(event) {
+ let input = event.target;
+ if (this.hasSyntaxError(input)) {
+ return super.onInput(event);
+ }
+
+ let termIndex = Number(input.parentNode.dataset.index);
+ let isTerm = termIndex >= 0;
+
+ if (! isTerm && (this.termType === 'operator' || this.termType === 'logical_operator')) {
+ let value = this.readPartialTerm(input);
+
+ if (value && ! this.validOperator(value).partialMatches) {
+ let defaultTerm = this.termType === 'operator'
+ ? { ...this.relational_operators[0] }
+ : { ...this.logical_operators[0] };
+
+ if (value !== defaultTerm.label) {
+ this.addTerm(defaultTerm);
+ this.togglePlaceholder();
+ } else {
+ this.exchangeTerm();
+ this.togglePlaceholder();
+ }
+ }
+ }
+
+ super.onInput(event);
+
+ if (isTerm && input.checkValidity()) {
+ let value = this.readPartialTerm(input);
+ if (value && ! ['column', 'value'].includes(input.parentNode.dataset.type)) {
+ this.autoSubmit(input, 'save', { terms: { [termIndex]: this.saveTerm(input) } });
+ }
+ }
+ }
+
+ onPaste(event) {
+ if (! this.hasTerms()) {
+ this.submitTerms(event.clipboardData.getData('text/plain'));
+ event.preventDefault();
+ } else if (! this.input.value) {
+ let terms = event.clipboardData.getData('text/plain');
+ if (this.termType === 'logical_operator') {
+ if (! this.validOperator(terms[0]).exactMatch) {
+ this.registerTerm({ ...this.logical_operators[0] });
+ }
+ } else if (this.termType !== 'column') {
+ return;
+ }
+
+ this.submitTerms(this.termsToQueryString(this.usedTerms) + terms);
+ event.preventDefault();
+ }
+ }
+ }
+
+ return FilterInput;
+});
diff --git a/asset/js/widget/SearchBar.js b/asset/js/widget/SearchBar.js
new file mode 100644
index 0000000..276de17
--- /dev/null
+++ b/asset/js/widget/SearchBar.js
@@ -0,0 +1,81 @@
+define(["../notjQuery"], function ($) {
+
+ "use strict";
+
+ class SearchBar {
+ constructor(form) {
+ this.form = form;
+ this.filterInput = null;
+ }
+
+ bind() {
+ $(this.form.parentNode).on('click', '[data-search-editor-url]', this.onOpenerClick, this);
+
+ return this;
+ }
+
+ refresh(form) {
+ if (form === this.form) {
+ // If the DOM node is still the same, nothing has changed
+ return;
+ }
+
+ this.form = form;
+ this.bind();
+ }
+
+ destroy() {
+ this.form = null;
+ this.filterInput = null;
+ }
+
+ setFilterInput(filterInput) {
+ this.filterInput = filterInput;
+
+ return this;
+ }
+
+ onOpenerClick(event) {
+ let opener = event.currentTarget;
+ let editorUrl = opener.dataset.searchEditorUrl;
+ let filterQueryString = this.filterInput.getQueryString();
+ let layout = document.getElementById('layout');
+
+ editorUrl += (editorUrl.indexOf('?') > -1 ? '&' : '?') + filterQueryString;
+
+ // Disable pointer events to block further function calls
+ opener.style.pointerEvents = 'none';
+
+ let observer = new MutationObserver((mutations) => {
+ for (let mutation of mutations) {
+ if (mutation.type === 'childList') {
+ mutation.removedNodes.forEach((node) => {
+ // Remove the pointerEvent none style to make the button clickable again
+ // after the modal has been removed
+ if (node.id === 'modal') {
+ opener.style.pointerEvents = '';
+ observer.disconnect();
+ }
+ });
+ }
+ }
+ });
+
+ observer.observe(layout, {childList: true});
+
+ // The search editor should open in a modal. We simulate a click on an anchor
+ // appropriately prepared so that Icinga Web 2 will handle it natively.
+ let a = document.createElement('a');
+ a.classList.add('modal-opener');
+ a.href = editorUrl;
+ a.dataset.noIcingaAjax = '';
+ a.dataset.icingaModal = '';
+
+ opener.parentNode.insertBefore(a, opener.nextSibling);
+ a.click();
+ a.remove();
+ }
+ }
+
+ return SearchBar;
+});
diff --git a/asset/js/widget/SearchEditor.js b/asset/js/widget/SearchEditor.js
new file mode 100644
index 0000000..b8235f0
--- /dev/null
+++ b/asset/js/widget/SearchEditor.js
@@ -0,0 +1,79 @@
+define(["../notjQuery", "../vendor/Sortable"], function ($, Sortable) {
+
+ "use strict";
+
+ class SearchEditor {
+ constructor(form) {
+ this.form = form;
+ }
+
+ bind() {
+ $(this.form).on('end', this.onRuleDropped, this);
+
+ this.form.querySelectorAll('ol').forEach(sortable => {
+ let options = {
+ scroll: true,
+ group: 'rules',
+ direction: 'vertical',
+ invertSwap: true,
+ handle: '.drag-initiator'
+ };
+
+ Sortable.create(sortable, options);
+ });
+
+ return this;
+ }
+
+ refresh(form) {
+ if (form === this.form) {
+ // If the DOM node is still the same, nothing has changed
+ return;
+ }
+
+ this.form = form;
+ this.bind();
+ }
+
+ destroy() {
+ this.form = null;
+ this.filterInput = null;
+ }
+
+ onRuleDropped(event) {
+ if (event.to === event.from && event.newIndex === event.oldIndex) {
+ // The user dropped the rule at its previous position
+ return;
+ }
+
+ let placement = 'before';
+ let neighbour = event.to.querySelector(':scope > :nth-child(' + (event.newIndex + 2) + ')');
+ if (! neighbour) {
+ // User dropped the rule at the end of a group
+ placement = 'after';
+ neighbour = event.to.querySelector(':scope > :nth-child(' + event.newIndex + ')')
+ if (! neighbour) {
+ // User dropped the rule into an empty group
+ placement = 'to';
+ neighbour = event.to.closest('[id]');
+ }
+ }
+
+ // It's a submit element, the very first one, otherwise Icinga Web 2 sends another "structural-change"
+ this.form.insertBefore(
+ $.render(
+ '<input type="hidden" name="structural-change[1]" value="' + placement + ':' + neighbour.id + '">'
+ ),
+ this.form.firstChild
+ );
+ this.form.insertBefore(
+ $.render('<input type="submit" name="structural-change[0]" value="move-rule:' + event.item.id + '">'),
+ this.form.firstChild
+ );
+
+ $(this.form).trigger('submit');
+ }
+ }
+
+ return SearchEditor;
+});
diff --git a/asset/js/widget/TermInput.js b/asset/js/widget/TermInput.js
new file mode 100644
index 0000000..537f4c4
--- /dev/null
+++ b/asset/js/widget/TermInput.js
@@ -0,0 +1,196 @@
+define(["../notjQuery", "BaseInput"], function ($, BaseInput) {
+
+ "use strict";
+
+ class TermInput extends BaseInput {
+ constructor(input) {
+ super(input);
+
+ this.separator = this.input.dataset.termSeparator || ' ';
+ this.ignoreSpaceUntil = null;
+ }
+
+ bind() {
+ super.bind();
+
+ // TODO: Compatibility only. Remove as soon as possible once Web 2.12 (?) is out.
+ // Or upon any other update which lets Web trigger a real submit upon auto submit.
+ $(this.input.form).on('change', 'select.autosubmit', this.onSubmit, this);
+ $(this.input.form).on('change', 'input.autosubmit', this.onSubmit, this);
+
+ return this;
+ }
+
+ reset() {
+ super.reset();
+
+ this.ignoreSpaceUntil = null;
+ }
+
+ readPartialTerm(input) {
+ let value = super.readPartialTerm(input);
+ if (value && this.ignoreSpaceUntil && value[0] === this.ignoreSpaceUntil) {
+ value = value.slice(1);
+ if (value.slice(-1) === this.ignoreSpaceUntil) {
+ value = value.slice(0, -1);
+ }
+ }
+
+ return value;
+ }
+
+ writePartialTerm(value, input) {
+ if (this.ignoreSpaceUntil !== null && this.ignoreSpaceSince === 0) {
+ value = this.ignoreSpaceUntil + value;
+ }
+
+ super.writePartialTerm(value, input);
+ }
+
+ readFullTerm(input, termIndex = null) {
+ let termData = super.readFullTerm(input, termIndex);
+ if (termData && this.ignoreSpaceUntil !== null && input.value[0] === this.ignoreSpaceUntil) {
+ if (input.value.slice(-1) !== this.ignoreSpaceUntil || input.value.length < 2) {
+ return false;
+ }
+
+ this.ignoreSpaceUntil = null;
+ }
+
+ return termData;
+ }
+
+ hasSyntaxError(input) {
+ if ((typeof input === 'undefined' || input === this.input) && this.ignoreSpaceUntil !== null) {
+ if (this.input.value === this.ignoreSpaceUntil) {
+ return true;
+ }
+ }
+
+ return super.hasSyntaxError(input);
+ }
+
+ termsToQueryString(terms) {
+ let quoted = [];
+ for (const termData of terms) {
+ let search = this.encodeTerm(termData).search;
+ if (search.indexOf(this.separator) >= 0) {
+ search = '"' + termData.search + '"';
+ }
+
+ quoted.push(search);
+ }
+
+ return quoted.join(this.separator).trim();
+ }
+
+ complete(input, data) {
+ data.exclude = this.usedTerms.map(termData => termData.search);
+
+ super.complete(input, data);
+ }
+
+ /**
+ * Event listeners
+ */
+
+ onSubmit(event) {
+ super.onSubmit(event);
+
+ this.ignoreSpaceUntil = null;
+ }
+
+ onInput(event) {
+ let label = event.target.parentNode;
+ if (label.dataset.index >= 0) {
+ super.onInput(event);
+ return;
+ }
+
+ let input = event.target;
+ let firstChar = input.value[0];
+
+ if (this.ignoreSpaceUntil !== null) {
+ // Reset if the user changes/removes the source char
+ if (firstChar !== this.ignoreSpaceUntil) {
+ this.ignoreSpaceUntil = null;
+ }
+ }
+
+ if (this.ignoreSpaceUntil === null && (firstChar === "'" || firstChar === '"')) {
+ this.ignoreSpaceUntil = firstChar;
+ }
+
+ super.onInput(event);
+ }
+
+ onKeyDown(event) {
+ super.onKeyDown(event);
+ if (event.defaultPrevented) {
+ return;
+ }
+
+ let label = event.target.parentNode;
+ if (label.dataset.index >= 0) {
+ return;
+ }
+
+ if (event.key !== this.separator && event.key !== 'Enter') {
+ return;
+ }
+
+ let addedTerms = this.exchangeTerm();
+ if (Object.keys(addedTerms).length) {
+ this.togglePlaceholder();
+ event.preventDefault();
+ this.autoSubmit(this.input, 'exchange', { terms: addedTerms });
+ }
+ }
+
+ onKeyUp(event) {
+ super.onKeyUp(event);
+
+ let label = event.target.parentNode;
+ if (label.dataset.index >= 0) {
+ return;
+ }
+
+ if (this.ignoreSpaceUntil !== null) {
+ // Reset if the user changes/removes the source char
+ let value = event.target.value;
+ if (value[this.ignoreSpaceSince] !== this.ignoreSpaceUntil) {
+ this.ignoreSpaceUntil = null;
+ this.ignoreSpaceSince = null;
+ }
+ }
+
+ let input = event.target;
+ switch (event.key) {
+ case '"':
+ case "'":
+ if (this.ignoreSpaceUntil === null) {
+ this.ignoreSpaceUntil = event.key;
+ this.ignoreSpaceSince = input.selectionStart - 1;
+ }
+ }
+ }
+
+ onButtonClick(event) {
+ if (! this.hasSyntaxError()) {
+ let addedTerms = this.exchangeTerm();
+ if (Object.keys(addedTerms).length) {
+ this.togglePlaceholder();
+ event.preventDefault();
+ this.autoSubmit(this.input, 'exchange', { terms: addedTerms });
+ this.ignoreSpaceUntil = null;
+
+ return;
+ }
+ }
+
+ super.onButtonClick(event);
+ }
+ }
+
+ return TermInput;
+});