diff options
Diffstat (limited to 'asset/js/widget')
-rw-r--r-- | asset/js/widget/BaseInput.js | 1049 | ||||
-rw-r--r-- | asset/js/widget/Completer.js | 750 | ||||
-rw-r--r-- | asset/js/widget/CopyToClipboard.js | 49 | ||||
-rw-r--r-- | asset/js/widget/FilterInput.js | 1521 | ||||
-rw-r--r-- | asset/js/widget/SearchBar.js | 81 | ||||
-rw-r--r-- | asset/js/widget/SearchEditor.js | 79 | ||||
-rw-r--r-- | asset/js/widget/TermInput.js | 196 |
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; +}); |