640 lines
20 KiB
JavaScript
640 lines
20 KiB
JavaScript
/************************************************************************
|
||
* This file is part of EspoCRM.
|
||
*
|
||
* EspoCRM – Open Source CRM application.
|
||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||
* Website: https://www.espocrm.com
|
||
*
|
||
* This program is free software: you can redistribute it and/or modify
|
||
* it under the terms of the GNU Affero General Public License as published by
|
||
* the Free Software Foundation, either version 3 of the License, or
|
||
* (at your option) any later version.
|
||
*
|
||
* This program is distributed in the hope that it will be useful,
|
||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
* GNU Affero General Public License for more details.
|
||
*
|
||
* You should have received a copy of the GNU Affero General Public License
|
||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
*
|
||
* The interactive user interfaces in modified source and object code versions
|
||
* of this program must display Appropriate Legal Notices, as required under
|
||
* Section 5 of the GNU Affero General Public License version 3.
|
||
*
|
||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||
************************************************************************/
|
||
|
||
/** @module module:ui/select */
|
||
|
||
import Selectize from 'lib!selectize';
|
||
|
||
/**
|
||
* @typedef module:ui/select~Options
|
||
* @type {Object}
|
||
* @property {boolean} [selectOnTab=false] To select on tab.
|
||
* @property {boolean} [matchAnyWord=false] To match any word when searching.
|
||
* @property {function(string, module:ui/select~OptionItemsCallback): void} [load] Loads additional items
|
||
* when typing in search.
|
||
* @property {function(string, module:ui/select~OptionItemFull): Number} [score] A score function scoring
|
||
* searched items.
|
||
* @property {'value'|'text'|'$order'|'$score'} [sortBy='$order'] Item sorting.
|
||
* @property {'asc'|'desc'} [sortDirection='asc'] Sort direction.
|
||
*/
|
||
|
||
/**
|
||
* @callback module:ui/select~OptionItemsCallback
|
||
* @param {module:ui/select~OptionItem[]} list An option item list.
|
||
*/
|
||
|
||
/**
|
||
* @typedef module:ui/select~OptionItem
|
||
* @type {Object}
|
||
* @property {string} value A value.
|
||
* @property {string} text A label.
|
||
*/
|
||
|
||
/**
|
||
* @typedef module:ui/select~OptionItemFull
|
||
* @type {Object}
|
||
* @property {string} value A value.
|
||
* @property {string} text A label.
|
||
* @property {Number} $order An order index.
|
||
*/
|
||
|
||
/**
|
||
* @module ui/select
|
||
*
|
||
* Important. The Selectize library is heavily customized to fix multitude of UIX issues.
|
||
* Upgrading is not advisable. Consider forking.
|
||
*/
|
||
const Select = {
|
||
/**
|
||
* @param {Element|JQuery} element An element.
|
||
* @param {module:ui/select~Options} [options] Options.
|
||
*/
|
||
init: function (element, options = {}) {
|
||
const score = options.score;
|
||
const $el = $(element);
|
||
|
||
options = Select.applyDefaultOptions(options || {});
|
||
|
||
const plugins = [];
|
||
|
||
Select.loadEspoSelectPlugin();
|
||
|
||
plugins.push('espo_select');
|
||
|
||
const itemClasses = {};
|
||
|
||
const allowedValues = $el.children().toArray().map(item => {
|
||
const value = item.getAttributeNode('value').value;
|
||
|
||
if (item.classList) {
|
||
itemClasses[value] = item.classList.toString();
|
||
}
|
||
|
||
return value;
|
||
});
|
||
|
||
let $relativeParent = null;
|
||
|
||
const $modalBody = $el.closest('.modal-body');
|
||
|
||
if ($modalBody.length) {
|
||
$relativeParent = $modalBody;
|
||
}
|
||
|
||
// noinspection JSUnusedGlobalSymbols
|
||
const selectizeOptions = {
|
||
sortField: [{field: options.sortBy, direction: options.sortDirection}],
|
||
load: options.load,
|
||
loadThrottle: 1,
|
||
plugins: plugins,
|
||
highlight: false,
|
||
selectOnTab: options.selectOnTab,
|
||
copyClassesToDropdown: false,
|
||
allowEmptyOption: allowedValues.includes(''),
|
||
showEmptyOptionInDropdown: true,
|
||
$relativeParent: $relativeParent,
|
||
render: {
|
||
item: function (data) {
|
||
return $('<div>')
|
||
.addClass('item')
|
||
.addClass(itemClasses[data.value] || '')
|
||
.text(data.text)
|
||
.get(0).outerHTML;
|
||
},
|
||
option: function (data) {
|
||
const $div = $('<div>')
|
||
.addClass('option')
|
||
.addClass(data.value === '' ? 'selectize-dropdown-emptyoptionlabel' : '')
|
||
.addClass(itemClasses[data.value] || '')
|
||
.val(data.value)
|
||
.text(data.text);
|
||
|
||
if (data.text === '') {
|
||
$div.html(' ');
|
||
}
|
||
|
||
return $div.get(0).outerHTML;
|
||
},
|
||
},
|
||
onDelete: function (values) {
|
||
while (values.length) {
|
||
this.removeItem(values.pop(), true);
|
||
}
|
||
|
||
// noinspection JSUnresolvedReference
|
||
this.showInput();
|
||
this.positionDropdown();
|
||
this.refreshOptions(true);
|
||
},
|
||
};
|
||
|
||
if (!options.matchAnyWord) {
|
||
/** @this Selectize */
|
||
selectizeOptions.score = function (search) {
|
||
// noinspection JSUnresolvedReference
|
||
const score = this.getScoreFunction(search);
|
||
|
||
search = search.toLowerCase();
|
||
|
||
return function (item) {
|
||
if (item.text.toLowerCase().indexOf(search) === 0) {
|
||
return score(item);
|
||
}
|
||
|
||
return 0;
|
||
};
|
||
};
|
||
}
|
||
|
||
if (options.matchAnyWord) {
|
||
/** @this Selectize */
|
||
selectizeOptions.score = function (search) {
|
||
// noinspection JSUnresolvedReference
|
||
const score = this.getScoreFunction(search);
|
||
|
||
search = search.toLowerCase();
|
||
|
||
return function (item) {
|
||
const text = item.text.toLowerCase();
|
||
|
||
if (
|
||
!text.split(' ').find(item => item.startsWith(search)) &&
|
||
!text.startsWith(search)
|
||
) {
|
||
return 0;
|
||
}
|
||
|
||
return score(item);
|
||
};
|
||
};
|
||
}
|
||
|
||
if (options.score) {
|
||
|
||
selectizeOptions.score = function (search) {
|
||
return function (item) {
|
||
return score(search, item);
|
||
};
|
||
};
|
||
}
|
||
|
||
$el.selectize(selectizeOptions);
|
||
},
|
||
|
||
/**
|
||
* Focus.
|
||
*
|
||
* @param {Element|JQuery} element An element.
|
||
* @param {{noTrigger?: boolean}} [options] Options.
|
||
*/
|
||
focus: function (element, options) {
|
||
const $el = $(element);
|
||
|
||
options = options || {};
|
||
|
||
if (
|
||
!$el[0] ||
|
||
!$el[0].selectize
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const selectize = $el[0].selectize;
|
||
|
||
if (options.noTrigger) {
|
||
selectize.focusNoTrigger = true;
|
||
}
|
||
|
||
selectize.focus();
|
||
|
||
if (options.noTrigger) {
|
||
setTimeout(() => selectize.focusNoTrigger = false, 100);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Set options.
|
||
* @todo Side effects may occur if called multiple times. Workaround is to clone and re-initialize. To be fixed.
|
||
*
|
||
* @param {Element|JQuery} element An element.
|
||
* @param {{value: string, text: string}[]} options Options.
|
||
*/
|
||
setOptions: function (element, options) {
|
||
const $el = $(element);
|
||
|
||
const selectize = $el.get(0).selectize;
|
||
|
||
selectize.clearOptions(true);
|
||
selectize.load(callback => {
|
||
callback(
|
||
options.map(item => {
|
||
return {
|
||
value: item.value,
|
||
text: item.text || item.label,
|
||
};
|
||
})
|
||
);
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Set value.
|
||
*
|
||
* @param {JQuery} $el An element.
|
||
* @param {string} value A value.
|
||
*/
|
||
setValue: function ($el, value) {
|
||
const selectize = $el.get(0).selectize;
|
||
|
||
selectize.setValue(value, true);
|
||
},
|
||
|
||
/**
|
||
* Destroy.
|
||
*
|
||
* @param {JQuery} $el An element.
|
||
*/
|
||
destroy: function ($el) {
|
||
if (!$el.length || !$el[0].selectize) {
|
||
return;
|
||
}
|
||
|
||
$el[0].selectize.destroy();
|
||
},
|
||
|
||
/**
|
||
* @private
|
||
* @param {module:ui/select~Options} options
|
||
* @return {module:ui/select~Options}
|
||
*/
|
||
applyDefaultOptions: function (options) {
|
||
options = Espo.Utils.clone(options);
|
||
|
||
const defaults = {
|
||
selectOnTab: false,
|
||
matchAnyWord: false,
|
||
sortBy: '$order',
|
||
sortDirection: 'asc',
|
||
};
|
||
|
||
for (const key in defaults) {
|
||
if (key in options) {
|
||
continue;
|
||
}
|
||
|
||
options[key] = defaults[key];
|
||
}
|
||
|
||
return options;
|
||
},
|
||
|
||
/**
|
||
* @private
|
||
*/
|
||
loadEspoSelectPlugin: function () {
|
||
if ('espo_select' in Selectize.plugins) {
|
||
return;
|
||
}
|
||
|
||
const IS_MAC = /Mac/.test(navigator.userAgent);
|
||
const KEY_BACKSPACE = 8;
|
||
|
||
Selectize.define('espo_select', function () {
|
||
const self = this;
|
||
|
||
this.setup = (function () {
|
||
const original = self.setup;
|
||
|
||
return function () {
|
||
original.apply(this, arguments);
|
||
|
||
self.selectedValue = self.items[0];
|
||
|
||
self.$dropdown
|
||
.on('mouseup', '[data-selectable]', function () {
|
||
$(document).off('mouseup.select');
|
||
|
||
return self.onOptionSelect.apply(self, arguments);
|
||
});
|
||
|
||
self.$dropdown
|
||
.on('mousedown', '[data-selectable]', function () {
|
||
// Prevent issue when down inside, up outside.
|
||
$(document).one('mouseup.select', function () {
|
||
self.focusOnControlSilently();
|
||
});
|
||
});
|
||
|
||
|
||
self.$control_input.css({'width': '4px'});
|
||
};
|
||
})();
|
||
|
||
this.focusOnControlSilently = function () {
|
||
self.preventReOpenOnFocus = true;
|
||
self.$control_input[0].focus();
|
||
self.preventReOpenOnFocus = false;
|
||
};
|
||
|
||
/*this.positionDropdown = (function () {
|
||
let original = self.positionDropdown;
|
||
|
||
return function () {
|
||
original.apply(this, arguments);
|
||
|
||
this.$dropdown.css({margin: 'unset'});
|
||
};
|
||
})();*/
|
||
|
||
this.refreshOptions = (function () {
|
||
const original = self.refreshOptions;
|
||
|
||
return function () {
|
||
if (self.focusNoTrigger) {
|
||
original.apply(this, [false]);
|
||
return;
|
||
}
|
||
|
||
original.apply(this, arguments);
|
||
};
|
||
})();
|
||
|
||
this.blur = (function () {
|
||
const original = self.blur;
|
||
|
||
return function () {
|
||
// Prevent closing on mouse down.
|
||
if (self.preventClose) {
|
||
return;
|
||
}
|
||
|
||
original.apply(this, arguments);
|
||
};
|
||
})();
|
||
|
||
this.close = (function () {
|
||
const original = self.close;
|
||
|
||
return function () {
|
||
if (self.preventClose) {
|
||
return;
|
||
}
|
||
|
||
original.apply(this, arguments);
|
||
};
|
||
})();
|
||
|
||
this.onOptionSelect = (function () {
|
||
const original = self.onOptionSelect;
|
||
|
||
return function (e) {
|
||
if (e.type === 'mousedown' || e.type === 'click') {
|
||
self.preventClose = true;
|
||
setTimeout(() => self.preventClose = false, 100);
|
||
|
||
return;
|
||
}
|
||
|
||
self.preventClose = false;
|
||
|
||
if (e.type === 'mouseup') {
|
||
setTimeout(() => self.focusOnControlSilently(), 50);
|
||
}
|
||
|
||
original.apply(this, arguments);
|
||
|
||
self.selectedValue = $(e.currentTarget).attr('data-value');
|
||
};
|
||
})();
|
||
|
||
this.open = (function() {
|
||
const original = self.open;
|
||
|
||
return function () {
|
||
const toProcess = !(self.isLocked || self.isOpen);
|
||
|
||
original.apply(this, arguments);
|
||
|
||
if (!toProcess) {
|
||
return;
|
||
}
|
||
|
||
const $dropdownContent = self.$dropdown.children().first();
|
||
const $selected = $dropdownContent.find('.selected');
|
||
|
||
if (!$selected.length) {
|
||
return;
|
||
}
|
||
|
||
let scrollTo = $selected.get(0).offsetTop - $dropdownContent.get(0).clientHeight;
|
||
scrollTo = scrollTo >= 0 ? scrollTo : 0;
|
||
|
||
$dropdownContent
|
||
.find('.selectize-dropdown-content')
|
||
.scrollTop(scrollTo);
|
||
};
|
||
})();
|
||
|
||
this.onMouseDown = (function() {
|
||
const original = self.onMouseDown;
|
||
|
||
return function (e) {
|
||
// Prevent flicking when clicking on input.
|
||
if (!self.isOpen && !self.isInputHidden && self.$control_input.val()) {
|
||
return;
|
||
}
|
||
|
||
if (self.isOpen) {
|
||
self.closedByMouseDown = true;
|
||
}
|
||
|
||
return original.apply(this, arguments);
|
||
};
|
||
})();
|
||
|
||
this.onFocus = (function() {
|
||
const original = self.onFocus;
|
||
|
||
return function (e) {
|
||
if (self.preventReOpenOnFocus) {
|
||
return;
|
||
}
|
||
|
||
if (self.closedByMouseDown) {
|
||
self.closedByMouseDown = false;
|
||
|
||
return;
|
||
}
|
||
|
||
self.closedByMouseDown = false;
|
||
|
||
return original.apply(this, arguments);
|
||
};
|
||
})();
|
||
|
||
this.restoreSelectedValue = function () {
|
||
if (this.preventRevertLoop) {
|
||
return;
|
||
}
|
||
|
||
this.preventRevertLoop = true;
|
||
setTimeout(() => this.preventRevertLoop = false, 10);
|
||
|
||
this.setValue(this.selectedValue, true);
|
||
};
|
||
|
||
this.onBlur = (function() {
|
||
const original = self.onBlur;
|
||
|
||
return function () {
|
||
// Prevent closing on mouse down.
|
||
if (self.preventClose) {
|
||
return;
|
||
}
|
||
|
||
self.restoreSelectedValue();
|
||
|
||
self.$control_input.css({width: '4px'});
|
||
|
||
return original.apply(this, arguments);
|
||
};
|
||
})();
|
||
|
||
this.onKeyDown = (function() {
|
||
const original = self.onKeyDown;
|
||
|
||
return function (e) {
|
||
if (IS_MAC ? e.metaKey : e.ctrlKey) {
|
||
if (!self.items.length) {
|
||
self.restoreSelectedValue();
|
||
self.focus();
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (e.code === 'Escape') {
|
||
if (self.isOpen || !self.isInputHidden) {
|
||
e.stopPropagation();
|
||
}
|
||
|
||
if (self.isOpen) {
|
||
self.close();
|
||
}
|
||
|
||
if (!self.isInputHidden) {
|
||
self.hideInput();
|
||
}
|
||
|
||
self.addItem(this.selectedValue, true);
|
||
}
|
||
|
||
if (self.isFull() || self.isInputHidden) {
|
||
if (
|
||
e.key.length === 1 &&
|
||
(
|
||
e.code.match(/Key[A-Z]/i) ||
|
||
e.key.match(/[0-9]/) ||
|
||
RegExp(/^\p{L}/, 'u').test(e.key) // is letter
|
||
)
|
||
) {
|
||
const keyCode = e.keyCode;
|
||
e.keyCode = KEY_BACKSPACE;
|
||
self.deleteSelection(e);
|
||
e.keyCode = keyCode;
|
||
|
||
self.$control_input.width(15);
|
||
}
|
||
}
|
||
|
||
return original.apply(this, arguments);
|
||
};
|
||
})();
|
||
|
||
this.positionDropdown = (function() {
|
||
const POSITION = {
|
||
top: 'top',
|
||
bottom: 'bottom',
|
||
};
|
||
|
||
return function() {
|
||
const $control = self.$control;
|
||
|
||
const offset = this.settings.dropdownParent === 'body' ?
|
||
$control.offset() :
|
||
$control.position();
|
||
|
||
offset.top += $control.outerHeight(true);
|
||
|
||
const dropdownHeight = self.$dropdown.prop('scrollHeight') + 5;
|
||
const controlPosTop = self.$control.get(0).getBoundingClientRect().top;
|
||
const wrapperHeight = self.$wrapper.height();
|
||
|
||
const controlPosBottom = self.$control.get(0).getBoundingClientRect().bottom;
|
||
|
||
const boundaryTop = !this.settings.$relativeParent ? 0 :
|
||
this.settings.$relativeParent.get(0).getBoundingClientRect().top;
|
||
|
||
const position =
|
||
controlPosTop + dropdownHeight + wrapperHeight > window.innerHeight &&
|
||
controlPosBottom - dropdownHeight - wrapperHeight >= boundaryTop ?
|
||
POSITION.top :
|
||
POSITION.bottom;
|
||
|
||
const styles = {
|
||
width: $control.outerWidth(),
|
||
left: offset.left,
|
||
};
|
||
|
||
if (position === POSITION.top) {
|
||
Object.assign(styles, {
|
||
bottom: offset.top,
|
||
top: 'unset',
|
||
margin: '0 0 0 0',
|
||
});
|
||
|
||
self.$dropdown.addClass('selectize-position-top');
|
||
} else {
|
||
Object.assign(styles, {
|
||
top: offset.top,
|
||
bottom: 'unset',
|
||
margin: '0 0 0 0',
|
||
});
|
||
|
||
self.$dropdown.removeClass('selectize-position-top');
|
||
}
|
||
|
||
self.$dropdown.css(styles);
|
||
}
|
||
})();
|
||
});
|
||
},
|
||
};
|
||
|
||
export default Select;
|