From ca27df06d92adef95b8a7658cd275c1dac6ae3a1 Mon Sep 17 00:00:00 2001 From: Yurii Date: Wed, 11 Mar 2026 22:17:27 +0200 Subject: [PATCH] last viewed ux improvement --- .../Resources/metadata/app/clientNavbar.json | 15 +- client/res/templates/notification/panel.tpl | 7 +- .../src/helpers/site/window-panel-helper.js | 131 ++++++++++ .../src/views/global-search/global-search.js | 7 +- client/src/views/global-search/panel.js | 58 ----- client/src/views/modals/last-viewed.js | 4 +- client/src/views/notification/badge.js | 7 +- client/src/views/notification/panel.js | 57 ----- client/src/views/site/navbar/last-viewed.js | 239 ++++++++++++++++++ frontend/less/espo-rtl/custom.less | 3 +- frontend/less/espo/custom.less | 14 +- frontend/less/espo/layout-side.less | 6 +- frontend/less/espo/layout-top.less | 3 +- frontend/less/glass/custom.less | 3 +- 14 files changed, 410 insertions(+), 144 deletions(-) create mode 100644 client/src/helpers/site/window-panel-helper.js create mode 100644 client/src/views/site/navbar/last-viewed.js diff --git a/application/Espo/Resources/metadata/app/clientNavbar.json b/application/Espo/Resources/metadata/app/clientNavbar.json index a420f0bcd7..bb1ecfb839 100644 --- a/application/Espo/Resources/metadata/app/clientNavbar.json +++ b/application/Espo/Resources/metadata/app/clientNavbar.json @@ -6,6 +6,12 @@ "order": 5, "disabled": false }, + "lastViewed": { + "view": "views/site/navbar/last-viewed", + "class": "", + "order": 7, + "disabled": false + }, "quickCreate": { "view": "views/site/navbar/quick-create", "class": "dropdown hidden-xs quick-create-container", @@ -37,15 +43,6 @@ "link": "#Preferences", "labelTranslation": "Global.labels.Preferences" }, - "lastViewed": { - "order": 0, - "groupIndex": 5, - "link": "#LastViewed", - "labelTranslation": "Global.scopeNamesPlural.LastViewed", - "configCheck": "!actionHistoryDisabled", - "handler": "handlers/navbar-menu", - "actionFunction": "lastViewed" - }, "about": { "order": 0, "groupIndex": 10, diff --git a/client/res/templates/notification/panel.tpl b/client/res/templates/notification/panel.tpl index c9b18f44b3..034fe93e3d 100644 --- a/client/res/templates/notification/panel.tpl +++ b/client/res/templates/notification/panel.tpl @@ -1,11 +1,14 @@
diff --git a/client/src/helpers/site/window-panel-helper.js b/client/src/helpers/site/window-panel-helper.js new file mode 100644 index 0000000000..818e24622f --- /dev/null +++ b/client/src/helpers/site/window-panel-helper.js @@ -0,0 +1,131 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM – Open Source CRM application. + * Copyright (C) 2014-2026 EspoCRM, Inc. + * 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 . + * + * 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. + ************************************************************************/ + +import {inject} from 'di'; +import ThemeManager from 'theme-manager'; + +export default class WindowPanelHelper { + + /** + * @type {import('view').default} + * @private + */ + view + + /** + * @private + * @type {boolean} + */ + overflowWasHidden = false + + /** + * @private + */ + onResizeBind + + /** + * @private + * @type {ThemeManager} + */ + @inject(ThemeManager) + themeManager + + /** + * @param {import('view').default} view + */ + constructor(view) { + this.view = view; + + view.listenToOnce(view, 'remove', () => { + window.removeEventListener('resize', this.onResizeBind) + + if (this.overflowWasHidden) { + document.body.style.overflow = 'unset'; + + this.overflowWasHidden = false; + } + }); + + this.onResizeBind = this.onResize.bind(this); + + window.addEventListener('resize', this.onResizeBind); + + this.navbarPanelHeightSpace = this.themeManager.getParam('navbarPanelHeightSpace') ?? 100; + this.navbarPanelBodyMaxHeight = this.themeManager.getParam('navbarPanelBodyMaxHeight') ?? 600; + this.xsWidth = this.themeManager.getParam('screenWidthXs'); + + this.onResize(); + } + + /** + * @private + */ + onResize() { + const windowHeight = window.innerHeight; + const windowWidth = window.innerWidth; + + const panelBody = this.view.element?.querySelector('.panel-body'); + const heading = this.view.element?.querySelector('.panel-heading'); + + if (!(panelBody instanceof HTMLElement)) { + return; + } + + const diffHeight = heading?.outerHeight ?? 0; + + const cssParams = {}; + + if (windowWidth <= this.xsWidth) { + cssParams.height = (windowHeight - diffHeight) + 'px'; + cssParams.overflow = 'auto'; + + document.body.style.overflow = 'hidden'; + + this.overflowWasHidden = true; + } else { + cssParams.height = 'unset'; + cssParams.overflow = 'none'; + + if (this.overflowWasHidden) { + panelBody.style.overflow = 'unset'; + + this.overflowWasHidden = false; + } + + if (windowHeight - this.navbarPanelBodyMaxHeight < this.navbarPanelHeightSpace) { + const maxHeight = windowHeight - this.navbarPanelHeightSpace; + + cssParams.maxHeight = maxHeight + 'px'; + } + } + + for (const [param, value] of Object.entries(cssParams)) { + panelBody.style[param] = value; + } + } +} diff --git a/client/src/views/global-search/global-search.js b/client/src/views/global-search/global-search.js index d019bcaf0c..041fd3c8c5 100644 --- a/client/src/views/global-search/global-search.js +++ b/client/src/views/global-search/global-search.js @@ -29,6 +29,7 @@ import Autocomplete from 'ui/autocomplete'; import TabsHelper from 'helpers/site/tabs'; import SiteNavbarItemView from 'views/site/navbar/item'; +import WindowPanelHelper from 'helpers/site/window-panel-helper'; /** @module views/global-search/global-search */ @@ -314,8 +315,10 @@ class GlobalSearchView extends SiteNavbarItemView { this.createView('panel', 'views/global-search/panel', { fullSelector: '#global-search-panel', collection: this.collection, - }, view => { - view.render(); + }).then(async view => { + await view.render(); + + new WindowPanelHelper(view); this.listenToOnce(view, 'close', this.closePanel); }); diff --git a/client/src/views/global-search/panel.js b/client/src/views/global-search/panel.js index 2b66f379b9..43e27bd36d 100644 --- a/client/src/views/global-search/panel.js +++ b/client/src/views/global-search/panel.js @@ -36,19 +36,6 @@ class GlobalSearchPanel extends View { this.addHandler('click', '[data-action="closePanel"]', () => this.close()); this.maxSize = this.getConfig().get('globalSearchMaxSize') || 10; - - this.navbarPanelHeightSpace = this.getThemeManager().getParam('navbarPanelHeightSpace') || 100; - this.navbarPanelBodyMaxHeight = this.getThemeManager().getParam('navbarPanelBodyMaxHeight') || 600; - } - - onRemove() { - $(window).off('resize.global-search-height'); - - if (this.overflowWasHidden) { - $('body').css('overflow', 'unset'); - - this.overflowWasHidden = false; - } } afterRender() { @@ -58,13 +45,6 @@ class GlobalSearchPanel extends View { this.collection.fetch() .then(() => this.createRecordView()) .then(view => view.render()); - - const $window = $(window); - - $window.off('resize.global-search-height'); - $window.on('resize.global-search-height', this.processSizing.bind(this)); - - this.processSizing(); } /** @@ -99,44 +79,6 @@ class GlobalSearchPanel extends View { }); } - processSizing() { - const $window = $(window); - - const windowHeight = $window.height(); - const windowWidth = $window.width(); - - const diffHeight = this.$el.find('.panel-heading').outerHeight(); - - const cssParams = {}; - - if (windowWidth <= this.getThemeManager().getParam('screenWidthXs')) { - cssParams.height = (windowHeight - diffHeight) + 'px'; - cssParams.overflow = 'auto'; - - $('body').css('overflow', 'hidden'); - - this.overflowWasHidden = true; - } - else { - cssParams.height = 'unset'; - cssParams.overflow = 'none'; - - if (this.overflowWasHidden) { - $('body').css('overflow', 'unset'); - - this.overflowWasHidden = false; - } - - if (windowHeight - this.navbarPanelBodyMaxHeight < this.navbarPanelHeightSpace) { - const maxHeight = windowHeight - this.navbarPanelHeightSpace; - - cssParams.maxHeight = maxHeight + 'px'; - } - } - - this.$el.find('.panel-body').css(cssParams); - } - close() { this.trigger('close'); } diff --git a/client/src/views/modals/last-viewed.js b/client/src/views/modals/last-viewed.js index a0c2dd1414..b93eb667b5 100644 --- a/client/src/views/modals/last-viewed.js +++ b/client/src/views/modals/last-viewed.js @@ -68,8 +68,7 @@ class LastViewedModalView extends ModalView { } loadList() { - const viewName = this.getMetadata().get('clientDefs.' + this.scope + '.recordViews.listLastViewed') || - 'views/record/list'; + const viewName = 'views/record/list'; this.listenToOnce(this.collection, 'sync', () => { this.createView('list', viewName, { @@ -79,7 +78,6 @@ class LastViewedModalView extends ModalView { checkboxes: false, massActionsDisabled: true, rowActionsView: false, - searchManager: this.searchManager, checkAllResultDisabled: true, buttonsDisabled: true, headerDisabled: true, diff --git a/client/src/views/notification/badge.js b/client/src/views/notification/badge.js index d0fdf930ef..79b77d91b1 100644 --- a/client/src/views/notification/badge.js +++ b/client/src/views/notification/badge.js @@ -29,6 +29,7 @@ import View from 'view'; import {inject} from 'di'; import WebSocketManager from 'web-socket-manager'; +import WindowPanelHelper from 'helpers/site/window-panel-helper'; class NotificationBadgeView extends View { @@ -556,8 +557,10 @@ class NotificationBadgeView extends View { this.createView('panel', 'views/notification/panel', { fullSelector: '#notifications-panel', - }, view => { - view.render(); + }).then(async view => { + await view.render(); + + new WindowPanelHelper(view); this.$el.closest('.navbar-body').removeClass('in'); diff --git a/client/src/views/notification/panel.js b/client/src/views/notification/panel.js index c8f374ad66..e2f94d80f7 100644 --- a/client/src/views/notification/panel.js +++ b/client/src/views/notification/panel.js @@ -55,18 +55,7 @@ class NotificationPanelView extends View { this.wait(promise); - this.navbarPanelHeightSpace = this.getThemeManager().getParam('navbarPanelHeightSpace') || 100; - this.navbarPanelBodyMaxHeight = this.getThemeManager().getParam('navbarPanelBodyMaxHeight') || 600; - this.once('remove', () => { - $(window).off('resize.notifications-height'); - - if (this.overflowWasHidden) { - $('body').css('overflow', 'unset'); - - this.overflowWasHidden = false; - } - if (this.collection) { this.collection.abortLastFetch(); } @@ -78,13 +67,6 @@ class NotificationPanelView extends View { .then(() => this.createRecordView()) .then(view => view.render()); - const $window = $(window); - - $window.off('resize.notifications-height'); - $window.on('resize.notifications-height', this.processSizing.bind(this)); - - this.processSizing(); - $('#navbar li.notifications-badge-container').addClass('open'); this.$el.find('> .panel').focus(); @@ -131,45 +113,6 @@ class NotificationPanelView extends View { .then(() => this.trigger('all-read')); } - processSizing() { - const $window = $(window); - const windowHeight = $window.height(); - const windowWidth = $window.width(); - - const diffHeight = this.$el.find('.panel-heading').outerHeight(); - - const cssParams = {}; - - if (windowWidth <= this.getThemeManager().getParam('screenWidthXs')) { - cssParams.height = (windowHeight - diffHeight) + 'px'; - cssParams.overflow = 'auto'; - - $('body').css('overflow', 'hidden'); - this.overflowWasHidden = true; - - this.$el.find('.panel-body').css(cssParams); - - return; - } - - cssParams.height = 'unset'; - cssParams.overflow = 'none'; - - if (this.overflowWasHidden) { - $('body').css('overflow', 'unset'); - - this.overflowWasHidden = false; - } - - if (windowHeight - this.navbarPanelBodyMaxHeight < this.navbarPanelHeightSpace) { - const maxHeight = windowHeight - this.navbarPanelHeightSpace; - - cssParams.maxHeight = maxHeight + 'px'; - } - - this.$el.find('.panel-body').css(cssParams); - } - close() { this.trigger('close'); } diff --git a/client/src/views/site/navbar/last-viewed.js b/client/src/views/site/navbar/last-viewed.js new file mode 100644 index 0000000000..85f87a1138 --- /dev/null +++ b/client/src/views/site/navbar/last-viewed.js @@ -0,0 +1,239 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM – Open Source CRM application. + * Copyright (C) 2014-2026 EspoCRM, Inc. + * 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 . + * + * 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. + ************************************************************************/ + +import SiteNavbarItemView from 'views/site/navbar/item'; +import View from 'view'; +import ListRecordView from 'views/record/list'; +import WindowPanelHelper from 'helpers/site/window-panel-helper'; + +// noinspection JSUnusedGlobalSymbols +export default class LastViewedSiteNavbarItemView extends SiteNavbarItemView { + + // language=Handlebars + templateContent = ` + +
+ ` + + /** + * @private + * @type {HTMLDivElement|null} + */ + panelElement = null + + isAvailable() { + return !this.getConfig().get('actionHistoryDisabled'); + } + + setup() { + this.addActionHandler('showLastViewed', () => this.showPanel()); + + this.onMouseUpBind = this.onMouseUp.bind(this); + this.onClickBind = this.onClick.bind(this); + } + + /** + * @private + */ + async showPanel() { + const container = this.element.querySelector('.last-viewed-panel-container'); + + if (!(container instanceof HTMLDivElement)) { + this.panelElement = null; + + throw Error("No last-viewed-panel-container container."); + } + + const panel = document.createElement('div'); + panel.id = 'last-viewed-panel'; + + this.panelElement = panel; + + container.append(panel); + + const view = new PanelView({ + onClose: () => this.closePanel(), + }); + + await this.assignView('panel', view, '#last-viewed-panel'); + await view.render(); + + new WindowPanelHelper(view); + + document.addEventListener('mouseup', this.onMouseUpBind); + document.addEventListener('click', this.onClickBind); + + await view.processFetch(); + } + + /** + * @private + */ + closePanel() { + if (this.hasView('panel')) { + this.getView('panel').remove(); + } + + this.panelElement?.parentElement?.removeChild(this.panelElement); + + document.removeEventListener('mouseup', this.onMouseUpBind); + document.removeEventListener('click', this.onClickBind); + } + + /** + * @param {MouseEvent} e + * @private + */ + onMouseUp(e) { + if (e.button !== 0) { + return; + } + + const target = e.target; + + if (!(target instanceof HTMLElement)) { + return; + } + + if ( + this.panelElement === target || + this.panelElement.contains(target) || + target.classList.contains('modal') || + target.closest('.dialog.modal') + ) { + return; + } + + return this.closePanel(); + } + + /** + * @param {MouseEvent} e + * @private + */ + onClick(e) { + const target = e.target; + + if (!(target instanceof HTMLAnchorElement)) { + return; + } + + if ( + target.dataset.action === 'showMore' || + target.dataset.action === 'showLastViewed' + ) { + return; + } + + setTimeout(() => this.closePanel(), 100); + } +} + +class PanelView extends View { + + // language=Handlebars + templateContent = ` + + ` + + /** + * + * @param {{ + * onClose: function(), + * }} options + */ + constructor(options) { + super(options); + + this.options = options; + } + + setup() { + this.addActionHandler('closePanel', () => this.options.onClose()); + + this.once('remove', () => { + + }); + } + + onRemove() { + this.collection?.abortLastFetch(); + } + + async processFetch() { + const collection = await this.getCollectionFactory().create('ActionHistoryRecord'); + collection.maxSize = this.getConfig().get('globalSearchMaxSize') ?? 10; + collection.url = 'LastViewed'; + + this.collection = collection; + + await collection.fetch(); + + const view = new ListRecordView({ + collection: collection, + fullSelector: this.containerSelector + ' .list-container', + selectable: false, + checkboxes: false, + massActionsDisabled: true, + rowActionsView: false, + checkAllResultDisabled: true, + buttonsDisabled: true, + headerDisabled: true, + layoutName: 'listForLastViewed', + layoutAclDisabled: true, + }); + + await this.assignView('list', view, '.list-container'); + await view.render(); + } +} diff --git a/frontend/less/espo-rtl/custom.less b/frontend/less/espo-rtl/custom.less index 24bdc6b311..3b92a3a169 100644 --- a/frontend/less/espo-rtl/custom.less +++ b/frontend/less/espo-rtl/custom.less @@ -194,7 +194,8 @@ td.cell[data-name="buttons"] > .btn-group { margin-right: 14px; } -#global-search-panel { +#global-search-panel, +#last-viewed-panel { left: 35px; right: auto; } diff --git a/frontend/less/espo/custom.less b/frontend/less/espo/custom.less index d65b5827c9..2f2d0f09a2 100644 --- a/frontend/less/espo/custom.less +++ b/frontend/less/espo/custom.less @@ -1961,18 +1961,21 @@ textarea.auto-height { overflow-x: hidden; } -#global-search-panel { +#global-search-panel, +#last-viewed-panel { > .panel { .panel-heading { .close-link { display: none; } } + > .panel-body { max-height: @navbar-panel-body-max-height; overflow-y: auto; overflow-x: hidden; padding-bottom: var(--panel-padding); + scrollbar-gutter: stable; } } } @@ -3114,7 +3117,8 @@ table.table td.cell .html-container { left: auto; } -#global-search-panel { +#global-search-panel, +#last-viewed-panel { position: absolute; width: @global-search-panel-width; z-index: 1001; @@ -3625,7 +3629,8 @@ table.table-admin-panel { height: 100%; } - #global-search-panel { + #global-search-panel, + #last-viewed-panel { position: fixed; width: 100%; top: 0; @@ -3635,7 +3640,8 @@ table.table-admin-panel { height: 100%; } - #global-search-panel { + #global-search-panel, + #last-viewed-panel { > .panel { .panel-heading { .close-link { diff --git a/frontend/less/espo/layout-side.less b/frontend/less/espo/layout-side.less index e0f970ae3b..aeb7cede74 100644 --- a/frontend/less/espo/layout-side.less +++ b/frontend/less/espo/layout-side.less @@ -940,10 +940,8 @@ body[data-navbar="side"] { border-radius: 0 0 var(--border-radius) var(--border-radius) !important; } - #global-search-panel { - margin-top: var(--top-bar-height); - } - + #global-search-panel, + #last-viewed-panel, #notifications-panel { margin-top: var(--top-bar-height); } diff --git a/frontend/less/espo/layout-top.less b/frontend/less/espo/layout-top.less index 0633ff8f13..7cedc0e9e2 100644 --- a/frontend/less/espo/layout-top.less +++ b/frontend/less/espo/layout-top.less @@ -191,7 +191,8 @@ body:not([data-navbar="side"]) { padding-top: var(--4px); } - #global-search-panel { + #global-search-panel, + #last-viewed-panel { margin-top: var(--1px); } diff --git a/frontend/less/glass/custom.less b/frontend/less/glass/custom.less index 5abe442e85..018489bf97 100644 --- a/frontend/less/glass/custom.less +++ b/frontend/less/glass/custom.less @@ -130,7 +130,8 @@ body { } #notifications-panel, - #global-search-panel { + #global-search-panel, + #last-viewed-panel { > .panel { backdrop-filter: @backdrop-filter; > .panel-heading {