From 04768127910fa021caeb58e24b7a6fdc27c71f26 Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Mon, 7 Oct 2024 15:37:08 +0300 Subject: [PATCH] tab-quick-search --- .../Espo/Resources/defaults/config.php | 1 + .../Resources/metadata/app/adminPanel.json | 18 +- .../templates/global-search/global-search.tpl | 2 + client/src/helpers/site/tabs.js | 76 ++++++- .../src/views/global-search/global-search.js | 208 ++++++++++++++++++ client/src/views/site/navbar.js | 51 ++--- schema/metadata/app/adminPanel.json | 4 + 7 files changed, 317 insertions(+), 43 deletions(-) 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 + * } panels */ + const panels = this.getMetadata().get(`app.adminPanel`) || {}; + + Object.entries(panels) + .map(it => it[1]) + .sort((a, b) => a.order - b.order) + .forEach(it => { + it.itemList + .filter(it => it.tabQuickSearch && it.label) + .forEach(it => { + const label = this.translate(it.label, 'labels', 'Admin'); + + list.push({ + label: this.translate(it.label, 'labels', 'Admin'), + url: it.url, + lowerLabel: label.toLowerCase(), + words: label.split(' ').map(it => it.toLowerCase()), + }); + }); + }); + + } + + return list.filter((item, index) => list.findIndex(it => it.lowerLabel === item.lowerLabel) === index) + } } export default GlobalSearchView; diff --git a/client/src/views/site/navbar.js b/client/src/views/site/navbar.js index 84ffe7483b..42a9fa0801 100644 --- a/client/src/views/site/navbar.js +++ b/client/src/views/site/navbar.js @@ -386,7 +386,8 @@ class NavbarSiteView extends View { this.getPreferences(), this.getUser(), this.getAcl(), - this.getMetadata() + this.getMetadata(), + this.getLanguage() ); const itemDefs = this.getMetadata().get('app.clientNavbar.items') || {}; @@ -506,6 +507,10 @@ class NavbarSiteView extends View { * @private */ setupGlobalSearch() { + if (this.getConfig().get('tabQuickSearch')) { + return; + } + let isAvailable = false; /** @type {string[]} */ @@ -1154,7 +1159,7 @@ class NavbarSiteView extends View { * colorsDisabled: boolean, * tabIconsDisabled: boolean, * }} params - * @param {Object|string} tab + * @param {Record|string} tab * @param {number} i * @param {Object} vars * @return {{ @@ -1172,7 +1177,6 @@ class NavbarSiteView extends View { * }} */ prepareTabItemDefs(params, tab, i, vars) { - let label; let link; let iconClass = null; @@ -1183,62 +1187,39 @@ class NavbarSiteView extends View { let name = tab; let aClassName = 'nav-link'; - const translateLabel = label => { - if (label.indexOf('$') === 0) { - return this.translate(label.slice(1), 'navbarTabs'); - } - - return label; - }; + const label = this.tabsHelper.getTranslatedTabLabel(tab); if (tab === 'Home') { - label = this.getLanguage().translate(tab); link = '#'; - } else if (typeof tab === 'object' && tab.type === 'divider') { + } else if (this.tabsHelper.isTabDivider(tab)) { isDivider = true; - label = tab.text; - aClassName = 'nav-divider-text'; - name = 'divider-' + i; - if (label) { - label = translateLabel(label); - } - } else if (typeof tab === 'object' && tab.type === 'url') { + aClassName = 'nav-divider-text'; + name = `divider-${i}`; + } else if (this.tabsHelper.isTabUrl(tab)) { isUrl = true; - label = tab.text || '#'; - name = 'url-' + i; + + name = `url-${i}`; link = tab.url || '#'; color = tab.color; iconClass = tab.iconClass; - if (label) { - label = translateLabel(label); - } - this.urlList.push({name: name, url: link}); - } else if (typeof tab === 'object') { + } else if (this.tabsHelper.isTabGroup(tab)) { isGroup = true; - label = tab.text || ''; color = tab.color; iconClass = tab.iconClass; - name = 'group-' + i; + name = `group-${i}`; link = null; aClassName = 'nav-link-group'; - - if (label) { - label = translateLabel(label); - } } else { - label = this.getLanguage().translate(tab, 'scopeNamesPlural'); link = '#' + tab; } - label = label || ''; - const shortLabel = label.substring(0, 2); if (!params.colorsDisabled && !isGroup && !isDivider) { diff --git a/schema/metadata/app/adminPanel.json b/schema/metadata/app/adminPanel.json index 3d1d02af7e..cce9189a90 100644 --- a/schema/metadata/app/adminPanel.json +++ b/schema/metadata/app/adminPanel.json @@ -44,6 +44,10 @@ "recordView": { "type": "string", "description": "A record view. Optional. Needed if you defined the URL like #Admin/{myName}. The record view displays settings fields. https://docs.espocrm.com/development/custom-config-parameters/" + }, + "tabQuickSearch": { + "type": "boolean", + "description": "Makes available in the navbar tab quick search. As if v8.5." } } }