diff --git a/application/Espo/Resources/defaults/config.php b/application/Espo/Resources/defaults/config.php index 9bc2ee2f54..5d1bb31c03 100644 --- a/application/Espo/Resources/defaults/config.php +++ b/application/Espo/Resources/defaults/config.php @@ -88,6 +88,7 @@ return [ 'sqlFailed' => false, ], 'authenticationMethod' => 'Espo', + 'tabQuickSearch' => true, 'globalSearchEntityList' => [ 'Account', 'Contact', diff --git a/application/Espo/Resources/metadata/app/adminPanel.json b/application/Espo/Resources/metadata/app/adminPanel.json index e1ff45c66e..eeb1e2d421 100644 --- a/application/Espo/Resources/metadata/app/adminPanel.json +++ b/application/Espo/Resources/metadata/app/adminPanel.json @@ -97,19 +97,22 @@ "url": "#Admin/users", "label": "Users", "iconClass": "fas fa-user", - "description": "users" + "description": "users", + "tabQuickSearch": true }, { "url": "#Admin/teams", "label": "Teams", "iconClass": "fas fa-users", - "description": "teams" + "description": "teams", + "tabQuickSearch": true }, { "url": "#Admin/roles", "label": "Roles", "iconClass": "fas fa-key", - "description": "roles" + "description": "roles", + "tabQuickSearch": true }, { "url": "#Admin/authLog", @@ -145,7 +148,8 @@ "url": "#Admin/entityManager", "label": "Entity Manager", "iconClass": "fas fa-tools", - "description": "entityManager" + "description": "entityManager", + "tabQuickSearch": true }, { "url": "#Admin/layouts", @@ -238,7 +242,8 @@ "url": "#Admin/portalUsers", "label": "Portal Users", "iconClass": "fas fa-user", - "description": "portalUsers" + "description": "portalUsers", + "tabQuickSearch": true }, { "url": "#Admin/portalRoles", @@ -256,7 +261,8 @@ "url": "#Admin/workingTimeCalendar", "label": "Working Time Calendars", "iconClass": "far fa-calendar-alt", - "description": "workingTimeCalendars" + "description": "workingTimeCalendars", + "tabQuickSearch": true }, { "url": "#Admin/layoutSets", diff --git a/client/res/templates/global-search/global-search.tpl b/client/res/templates/global-search/global-search.tpl index fffd3e4c35..92c276d3a0 100644 --- a/client/res/templates/global-search/global-search.tpl +++ b/client/res/templates/global-search/global-search.tpl @@ -6,6 +6,7 @@ autocomplete="espo-global-search" spellcheck="false" > + {{#if hasSearchButton}}
+ {{/if}} diff --git a/client/src/helpers/site/tabs.js b/client/src/helpers/site/tabs.js index a3ea349b65..11b2f837f1 100644 --- a/client/src/helpers/site/tabs.js +++ b/client/src/helpers/site/tabs.js @@ -34,8 +34,9 @@ export default class TabsHelper { * @param {import('models/user').default} user * @param {import('acl-manager').default} acl * @param {import('metadata').default} metadata + * @param {import('language').default} language */ - constructor(config, preferences, user, acl, metadata) { + constructor(config, preferences, user, acl, metadata, language) { /** @private */ this.config = config; /** @private */ @@ -46,12 +47,22 @@ export default class TabsHelper { this.acl = acl; /** @private */ this.metadata = metadata; + /** @private */ + this.language = language; } + /** + * @typedef {Object} TabsHelper~item + * @property {string} [url] + * @property {string} [text] + * @property {'url'|'divider'} [type] + * @property {(TabsHelper~item|string)[]} [itemList] + */ + /** * Get the tab list. * - * @return {(Object|string)[]} + * @return {(TabsHelper~item|string)[]} */ getTabList() { let tabList = this.preferences.get('useCustomTabList') && !this.preferences.get('addCustomTabs') ? @@ -96,6 +107,67 @@ export default class TabsHelper { } /** + * Is a tab a group. + * + * @param {string|{type?: string}} item + */ + isTabGroup(item) { + if (!this.isTabDivider(item) && !this.isTabUrl(item) && typeof item === 'object') { + return true; + } + + return false; + } + + /** + * Is a tab a scope. + * + * @param {string|{type?: string}} item + */ + isTabScope(item) { + if (typeof item === 'object' || this.isTabMoreDelimiter(item) || item === 'Home') { + return false; + } + + return true; + } + + /** + * Get a translated tab label. + * + * @param {{text?: string}|string} item + */ + getTranslatedTabLabel(item) { + const translateLabel = label => { + if (label.indexOf('$') === 0) { + return this.language.translate(label.slice(1), 'navbarTabs'); + } + + return label; + }; + + if (this.isTabDivider(item) || this.isTabUrl(item) || this.isTabUrl(item) || this.isTabGroup(item)) { + if (item.text) { + return translateLabel(item.text); + } + + return '' + } + + if (item === 'Home') { + return this.language.translate('Home'); + } + + if (typeof item === 'object') { + return ''; + } + + return this.language.translate(item, 'scopeNamesPlural'); + } + + /** + * Check tab access. + * * @param {Record|string} item * @return {boolean} */ diff --git a/client/src/views/global-search/global-search.js b/client/src/views/global-search/global-search.js index 18f40aeee6..308881e88a 100644 --- a/client/src/views/global-search/global-search.js +++ b/client/src/views/global-search/global-search.js @@ -27,6 +27,10 @@ ************************************************************************/ import View from 'view'; +import Autocomplete from 'ui/autocomplete'; +import TabsHelper from 'helpers/site/tabs'; + +/** @module views/global-search/global-search */ class GlobalSearchView extends View { @@ -38,6 +42,48 @@ class GlobalSearchView extends View { */ containerElement + /** + * @private + * @type {HTMLInputElement} + */ + inputElement + + /** + * @private + * @type {boolean} + */ + tabQuickSearch + + /** + * @private + * @type {boolean} + */ + hasGlobalSearch + + /** + * @private + * @type {TabsHelper} + */ + tabsHelper + + /** + * @private + * @type {Autocomplete} + */ + autocomplete + + /** + * @private + * @type {module:views/global-search/global-search~tabData[]} + */ + tabDataList + + data() { + return { + hasSearchButton: this.hasGlobalSearch, + }; + } + setup() { this.addHandler('keydown', 'input.global-search-input', 'onKeydown'); this.addHandler('focus', 'input.global-search-input', 'onFocus'); @@ -54,10 +100,25 @@ class GlobalSearchView extends View { this.onMouseUpBind = this.onMouseUp.bind(this); this.onClickBind = this.onClick.bind(this); + + this.tabQuickSearch = this.getConfig().get('tabQuickSearch') || false; + this.hasGlobalSearch = (this.getConfig().get('globalSearchEntityList') || []).length > 0; + + this.tabsHelper = new TabsHelper( + this.getConfig(), + this.getPreferences(), + this.getUser(), + this.getAcl(), + this.getMetadata(), + this.getLanguage() + ); + + this.tabDataList = this.getTabDataList(); } /** * @param {MouseEvent} e + * @private */ onFocus(e) { const inputElement = /** @type {HTMLInputElement} */e.target; @@ -67,8 +128,13 @@ class GlobalSearchView extends View { /** * @param {KeyboardEvent} e + * @private */ onKeydown(e) { + if (!this.hasGlobalSearch) { + return; + } + const key = Espo.Utils.getKeyFromKeyEvent(e); if (e.code === 'Enter' || key === 'Enter' || key === 'Control+Enter') { @@ -84,6 +150,53 @@ class GlobalSearchView extends View { afterRender() { this.$input = this.$el.find('input.global-search-input'); + + this.inputElement = this.$input.get(0); + + if (this.tabQuickSearch) { + this.autocomplete = new Autocomplete(this.inputElement, { + minChars: 1, + lookupFunction: async query => { + const lower = query.toLowerCase(); + + return this.tabDataList + .filter(it => { + if (it.words.find(word => word.startsWith(lower))) { + return true; + } + + if (it.lowerLabel.toLowerCase().startsWith(lower)) { + return true; + } + + return false; + }) + .map(it => ({ + value: it.label, + url: it.url, + })); + }, + formatResult: /** {value: string, url: string} */item => { + const a = document.createElement('a'); + + a.text = item.value; + a.href = item.url; + a.classList.add('text-default'); + + return a.outerHTML; + }, + onSelect: /** {value: string, url: string} */item => { + window.location.href =item.url; + + this.inputElement.value = ''; + }, + }); + + this.once('render remove', () => { + this.autocomplete.dispose(); + this.autocomplete = undefined; + }); + } } /** @@ -151,6 +264,10 @@ class GlobalSearchView extends View { showPanel() { this.closePanel(); + if (this.autocomplete) { + this.autocomplete.hide(); + } + if (this.closeNavbarOnShow) { this.$el.closest('.navbar-body').removeClass('in'); } @@ -189,6 +306,97 @@ class GlobalSearchView extends View { document.removeEventListener('mouseup', this.onMouseUpBind); document.removeEventListener('click', this.onClickBind); } + + /** + * @typedef {Object} module:views/global-search/global-search~tabData + * @property {string} url + * @property {string} label + * @property {string} lowerLabel + * @property {string[]} words + */ + + /** + * @private + * @return {module:views/global-search/global-search~tabData[]} + */ + getTabDataList() { + /** @type {module:views/global-search/global-search~tabData[]}*/ + const list = []; + + /** + * + * @param {string|TabsHelper~item} item + * @return {module:views/global-search/global-search~tabData} + */ + const toData = (item) => { + const label = this.tabsHelper.getTranslatedTabLabel(item); + + const url = this.tabsHelper.isTabScope(item) ? `#${item}` : item.url; + + return { + url: url, + label: label, + words: label.split(' ').map(it => it.toLowerCase()), + lowerLabel: label.toLowerCase(), + }; + }; + + const checkTab = (item) => { + return (this.tabsHelper.isTabScope(item) || this.tabsHelper.isTabUrl(item)) && + this.tabsHelper.checkTabAccess(item) + } + + for (const item of this.tabsHelper.getTabList()) { + if (checkTab(item)) { + list.push(toData(item)); + + continue; + } + + if (this.tabsHelper.isTabGroup(item)) { + for (const subItem of item.itemList) { + if (checkTab(subItem)) { + list.push(toData(subItem)); + } + } + } + } + + if (this.getUser().isAdmin()) { + /** @type { + * Record