last viewed ux improvement

This commit is contained in:
Yurii
2026-03-11 22:17:27 +02:00
parent a7f2faa53b
commit ca27df06d9
14 changed files with 410 additions and 144 deletions
@@ -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,
+5 -2
View File
@@ -1,11 +1,14 @@
<div class="panel panel-default no-focus-outline" tabindex="-1">
<div class="panel-heading panel-heading-no-title">
<div class="link-group">
<a href="#Notification" data-action="openNotifications">{{translate 'View List'}}</a>
<a role="button" tabindex="0" data-action="markAllNotificationsRead">{{translate 'Mark all read'}}</a>
<a role="button" tabindex="0" class="close-link" data-action="closePanel"><span class="fas fa-times"></span></a>
</div>
{{translate 'Notifications'}}
<a
href="#Notification"
class="text-soft"
data-action="openNotifications"
>{{translate 'Notifications'}}</a>
</div>
<div class="panel-body">
<div class="list-container">
@@ -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 <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.
************************************************************************/
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;
}
}
}
@@ -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);
});
-58
View File
@@ -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');
}
+1 -3
View File
@@ -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,
+5 -2
View File
@@ -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');
-57
View File
@@ -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');
}
+239
View File
@@ -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 <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.
************************************************************************/
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 = `
<a
role="button"
tabindex="0"
data-action="showLastViewed"
title="{{translate 'LastViewed' category='scopeNamesPlural'}}"
><span class="fas fa-clock-rotate-left icon"></span></a>
<div class="last-viewed-panel-container"></div>
`
/**
* @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 = `
<div class="panel panel-default">
<div class="panel-heading panel-heading-no-title">
<div class="link-group">
<a
role="button"
tabindex="0"
class="close-link"
data-action="closePanel"
><span class="fas fa-times"></span></a>
</div>
<a
href="#LastViewed"
class="text-soft"
>{{translate 'LastViewed' category='scopeNamesPlural'}}</a>
</div>
<div class="panel-body">
<div class="list-container">
<span class="text-soft fas fa-spinner fa-spin"></span>
</div>
</div>
</div>
`
/**
*
* @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();
}
}
+2 -1
View File
@@ -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;
}
+10 -4
View File
@@ -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 {
+2 -4
View File
@@ -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);
}
+2 -1
View File
@@ -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);
}
+2 -1
View File
@@ -130,7 +130,8 @@ body {
}
#notifications-panel,
#global-search-panel {
#global-search-panel,
#last-viewed-panel {
> .panel {
backdrop-filter: @backdrop-filter;
> .panel-heading {