From 26634de090b55da7aabb6b0ca0ca4ac297773aa3 Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Tue, 25 Apr 2023 16:44:10 +0300 Subject: [PATCH] stored text search --- client/src/helpers/misc/stored-text-search.js | 148 ++++++++++++++++++ client/src/search-manager.js | 3 + client/src/views/record/search.js | 92 ++++++++++- frontend/less/espo/custom.less | 21 +++ 4 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 client/src/helpers/misc/stored-text-search.js diff --git a/client/src/helpers/misc/stored-text-search.js b/client/src/helpers/misc/stored-text-search.js new file mode 100644 index 0000000000..2bd7f5818d --- /dev/null +++ b/client/src/helpers/misc/stored-text-search.js @@ -0,0 +1,148 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM - Open Source CRM application. + * Copyright (C) 2014-2023 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko + * Website: https://www.espocrm.com + * + * EspoCRM is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * EspoCRM 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with EspoCRM. If not, see http://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 General Public License version 3. + * + * In accordance with Section 7(b) of the GNU General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +define('helpers/misc/stored-text-search', [], function () { + + /** + * @memberOf module:helpers/misc/stored-text-search + */ + class Class { + /** + * @param {module:storage.Class} storage + * @param {string} scope + * @param {Number} [maxCount] + */ + constructor(scope, storage, maxCount) { + this.scope = scope; + this.storage = storage; + this.key = 'textSearches'; + this.maxCount = maxCount || 100; + /** @type {string[]|null} */ + this.list = null; + } + + /** + * Match. + * + * @param {string} text + * @param {Number} [limit] + * @return {string[]} + */ + match(text, limit) { + text = text.toLowerCase().trim(); + + let list = this.get(); + let matchedList = []; + + for (let item of list) { + if (item.toLowerCase().startsWith(text)) { + matchedList.push(item); + } + + if (limit !== undefined && matchedList.length === limit) { + break; + } + } + + return matchedList; + } + + /** + * Get stored text filters. + * + * @private + * @return {string[]} + */ + get() { + if (this.list === null) { + this.list = this.getFromStorage(); + } + + return this.list; + } + + /** + * @private + * @return {string[]} + */ + getFromStorage() { + /** @var {string[]} */ + return this.storage.get(this.key, this.scope) || []; + } + + /** + * Store a text filter. + * + * @param {string} text + */ + store(text) { + text = text.trim(); + + let list = this.getFromStorage(); + + let index = list.indexOf(text); + + if (index !== -1) { + list.splice(index, 1); + } + + list.unshift(text); + + if (list.length > this.maxCount) { + list = list.slice(0, this.maxCount); + } + + this.list = list; + this.storage.set(this.key, this.scope, list); + } + + /** + * Remove a text filter. + * + * @param {string} text + */ + remove(text) { + text = text.trim(); + + let list = this.getFromStorage(); + + let index = list.indexOf(text); + + if (index === -1) { + return; + } + + list.splice(index, 1); + + this.list = list; + this.storage.set(this.key, this.scope, list); + } + } + + return Class; +}); diff --git a/client/src/search-manager.js b/client/src/search-manager.js index d46ebe8ed1..15bfa34230 100644 --- a/client/src/search-manager.js +++ b/client/src/search-manager.js @@ -349,6 +349,9 @@ define('search-manager', [], function () { this.data = data; if (this.storage) { + data = Espo.Utils.clone(data); + delete data['textFilter']; + this.storage.set(this.type + 'Search', this.scope, data); } }, diff --git a/client/src/views/record/search.js b/client/src/views/record/search.js index a613300403..91359525bd 100644 --- a/client/src/views/record/search.js +++ b/client/src/views/record/search.js @@ -26,7 +26,7 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -define('views/record/search', ['view'], function (Dep) { +define('views/record/search', ['view', 'helpers/misc/stored-text-search'], function (Dep, StoredTextSearch) { /** * @class @@ -39,6 +39,7 @@ define('views/record/search', ['view'], function (Dep) { template: 'record/search', scope: null, + /** @type {module:search-manager.Class} */ searchManager: null, fieldFilterList: null, /** @type {Object.|null}*/ @@ -63,6 +64,8 @@ define('views/record/search', ['view'], function (Dep) { FIELD_QUICK_SEARCH_COUNT_THRESHOLD: 4, + autocompleteLimit: 7, + data: function () { return { scope: this.scope, @@ -90,8 +93,15 @@ define('views/record/search', ['view'], function (Dep) { this.entityType = this.collection.name; this.scope = this.options.scope || this.entityType; + /** @type {module:search-manager.Class} */ this.searchManager = this.options.searchManager; + /** + * @type {module:helpers/misc/stored-text-search.Class} + * @private + */ + this.storedTextSearchHelper = new StoredTextSearch(this.scope, this.getHelper().storage); + this.textFilterDisabled = this.options.textFilterDisabled || this.textFilterDisabled || this.getMetadata().get(['clientDefs', this.scope, 'textFilterDisabled']); @@ -671,6 +681,8 @@ define('views/record/search', ['view'], function (Dep) { this.$fieldQuickSearch = this.$filterList.find('input.field-filter-quick-search-input'); /** @type {JQuery} */ this.$addFilterButton = this.$el.find('button.add-filter-button'); + /** @type {JQuery} */ + this.$textFilter = this.$el.find('input.text-filter'); this.updateAddFilterButton(); @@ -679,6 +691,75 @@ define('views/record/search', ['view'], function (Dep) { this.manageLabels(); this.controlResetButtonVisibility(); this.initQuickSearchUi(); + this.initTextSearchAutocomplete(); + }, + + initTextSearchAutocomplete: function () { + /*this.$textFilter.on('blur.autocomplete', e => { + e.stopPropagation(); + e.preventDefault(); + console.log('b'); + });*/ + + let options = { + minChars: 0, + noCache: true, + triggerSelectOnValidInput: false, + beforeRender: ($container) => { + $container.addClass('text-search-suggestions'); + + $container.find('a[data-action="clearStoredTextSearch"]').on('click', e => { + e.stopPropagation(); + e.preventDefault(); + + let text = e.currentTarget.getAttribute('data-value'); + + this.storedTextSearchHelper.remove(text); + + setTimeout(() => { + this.$textFilter.focus(); + }, 205); + }); + }, + formatResult: item => { + return $('') + .append( + $('') + .attr('data-action', 'clearStoredTextSearch') + .attr('role', 'button') + .attr('data-value', item.value) + .attr('title', this.translate('Remove')) + .html('') + .addClass('pull-right text-soft'), + $('') + .text(item.value) + ) + .get(0).innerHTML; + }, + lookup: (text, done) => { + let suggestions = this.storedTextSearchHelper.match(text, this.autocompleteLimit) + .map(item => { + return {value: item}; + }); + + done({suggestions: suggestions}); + }, + onSelect: () => { + this.$textFilter.focus(); + this.$textFilter.autocomplete('hide'); + }, + }; + + this.$textFilter.autocomplete(options); + + this.$textFilter.on('focus', () => { + if (this.$textFilter.val()) { + this.$textFilter.autocomplete('hide'); + } + }); + + this.once('render', () => this.$textFilter.autocomplete('dispose')); + this.once('remove', () => this.$textFilter.autocomplete('dispose')); }, initQuickSearchUi: function () { @@ -839,6 +920,7 @@ define('views/record/search', ['view'], function (Dep) { this.updateSearch(); this.updateCollection(); this.controlResetButtonVisibility(); + this.storeTextSearch(); this.isSearchedWithAdvancedFilter = this.hasAdvancedFilter(); }, @@ -1197,5 +1279,13 @@ define('views/record/search', ['view'], function (Dep) { .find('[data-toggle="dropdown"]') .dropdown('toggle'); }, + + storeTextSearch: function () { + if (!this.textFilter) { + return; + } + + this.storedTextSearchHelper.store(this.textFilter); + }, }); }); diff --git a/frontend/less/espo/custom.less b/frontend/less/espo/custom.less index 5b8285efca..7fb7d31cfe 100644 --- a/frontend/less/espo/custom.less +++ b/frontend/less/espo/custom.less @@ -3748,6 +3748,27 @@ a.close:hover { } } +body > .autocomplete-suggestions.text-search-suggestions { + > .autocomplete-suggestion { + padding-top: 0; + padding-bottom: 0; + + > span { + text-overflow: ellipsis; + overflow: hidden; + display: inline-block; + width: ~"calc(100% - 20px)"; + height: 24px; + padding-top: 4px; + padding-bottom: 4px; + } + + > a { + margin-top: 3px; + } + } +} + @import "elements/site.less"; @import "elements/modal.less"; @import "elements/buttons.less";