From 2c0d5664dd655050fc993aa48f15f1adfb30496e Mon Sep 17 00:00:00 2001 From: Yurii Kuznietsov Date: Thu, 16 Apr 2026 18:59:00 +0300 Subject: [PATCH] F/collapse notification (#3644) * dev * Dev * Dev * Dev --- .../Espo/Resources/i18n/en_US/Global.json | 3 +- .../templates/meeting/popup-notification.tpl | 23 +- .../src/views/meeting/popup-notification.js | 6 + client/src/views/collapsed-modal-bar.js | 53 +++- client/src/views/collapsed-modal.js | 2 +- client/src/views/notification/badge.js | 243 ++++++++++++++---- client/src/views/popup-notification.js | 123 ++++++++- .../espo/elements/popup-notification.less | 7 +- 8 files changed, 402 insertions(+), 58 deletions(-) diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index 5a73cd4ecc..58fb751335 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -344,7 +344,8 @@ "Reactions": "Reactions", "Lock": "Lock", "Unlock": "Unlock", - "Assigned": "Assigned" + "Assigned": "Assigned", + "Notification": "Notification" }, "messages": { "cannotRemoveUsed": "Cannot remove the record as it is used.", diff --git a/client/modules/crm/res/templates/meeting/popup-notification.tpl b/client/modules/crm/res/templates/meeting/popup-notification.tpl index f6804588c9..70d1b2c541 100644 --- a/client/modules/crm/res/templates/meeting/popup-notification.tpl +++ b/client/modules/crm/res/templates/meeting/popup-notification.tpl @@ -1,6 +1,23 @@ -{{#if closeButton}} - -{{/if}} +{{#if closeButton~}} + +{{~/if~}} +{{#if collapseButton~}} + +{{~/if}}

{{header}}

diff --git a/client/modules/crm/src/views/meeting/popup-notification.js b/client/modules/crm/src/views/meeting/popup-notification.js index 36d694f434..cec2a5c559 100644 --- a/client/modules/crm/src/views/meeting/popup-notification.js +++ b/client/modules/crm/src/views/meeting/popup-notification.js @@ -35,6 +35,7 @@ class MeetingPopupNotificationView extends PopupNotificationView { type = 'event' style = 'primary' closeButton = true + collapseButton = true setup() { if (!this.notificationData.entityType) { @@ -71,6 +72,11 @@ class MeetingPopupNotificationView extends PopupNotificationView { onCancel() { Espo.Ajax.postRequest('Activities/action/removePopupNotification', {id: this.notificationId}); } + + getTitle() { + return this.notificationData.name ?? + this.translate(this.notificationData.entityType, 'scopeNames'); + } } export default MeetingPopupNotificationView; diff --git a/client/src/views/collapsed-modal-bar.js b/client/src/views/collapsed-modal-bar.js index 6c6f934ef1..92498dce1a 100644 --- a/client/src/views/collapsed-modal-bar.js +++ b/client/src/views/collapsed-modal-bar.js @@ -28,6 +28,7 @@ import View from 'view'; import CollapsedModalView from 'views/collapsed-modal'; +import PopupNotificationView from 'views/popup-notification'; class CollapsedModalBarView extends View { @@ -56,6 +57,12 @@ class CollapsedModalBarView extends View { */ lastNumber + /** + * @private + * @type {WeakMap} + */ + map + data() { return { dataList: this.getDataList(), @@ -78,6 +85,8 @@ class CollapsedModalBarView extends View { setup() { this.lastNumber = 0; this.numberList = []; + + this.map = new WeakMap(); } /** @@ -154,7 +163,7 @@ class CollapsedModalBarView extends View { } /** - * @param {import('views/modal').default} modalView + * @param {import('views/modal').default|import('views/popup-notification').default} modalView * @param {{title: string}} options */ async addModalView(modalView, options) { @@ -166,16 +175,30 @@ class CollapsedModalBarView extends View { this.lastNumber++; + this.map.set(modalView, number); + const view = new CollapsedModalView({ modalView: modalView, title: options.title, duplicateNumber: this.calculateDuplicateNumber(options.title), - onClose: () => this.removeModalView(number), + onClose: () => { + this.removeModalViewByNumber(number); + + if (modalView instanceof PopupNotificationView) { + modalView.resolveCancel(); + } + }, onExpand: () => { - this.removeModalView(number, true); + this.removeModalViewByNumber(number, true); // Use timeout to prevent DOM being updated after modal is re-rendered. setTimeout(async () => { + if (modalView instanceof PopupNotificationView) { + this.expandPopupNotification(modalView); + + return; + } + const key = `dialog-${number}`; this.setView(key, modalView); @@ -193,11 +216,25 @@ class CollapsedModalBarView extends View { await this.reRender(true); } + /** + * @param {import('views/modal').default|import('views/popup-notification').default} modalView + * @since 10.0 + */ + removeModalView(modalView) { + const number = this.map.get(modalView); + + if (number === undefined) { + return; + } + + this.removeModalViewByNumber(number); + } + /** * @param {number} number * @param {boolean} [noReRender] */ - removeModalView(number, noReRender = false) { + removeModalViewByNumber(number, noReRender = false) { const key = this.composeKey(number); const index = this.numberList.indexOf(number); @@ -229,6 +266,14 @@ class CollapsedModalBarView extends View { composeKey(number) { return `key-${number}`; } + + /** + * @private + * @param {PopupNotificationView} view + */ + expandPopupNotification(view) { + view.expand(); + } } export default CollapsedModalBarView; diff --git a/client/src/views/collapsed-modal.js b/client/src/views/collapsed-modal.js index 9bd0474f13..7b160461be 100644 --- a/client/src/views/collapsed-modal.js +++ b/client/src/views/collapsed-modal.js @@ -57,7 +57,7 @@ class CollapsedModalView extends View { /** * @param {{ - * modalView: import('views/modal').default, + * modalView: import('views/modal').default|import('views/popup-notification').default, * onClose: function(), * onExpand: function(), * duplicateNumber?: number|null, diff --git a/client/src/views/notification/badge.js b/client/src/views/notification/badge.js index 79b77d91b1..7c37818e60 100644 --- a/client/src/views/notification/badge.js +++ b/client/src/views/notification/badge.js @@ -30,6 +30,7 @@ import View from 'view'; import {inject} from 'di'; import WebSocketManager from 'web-socket-manager'; import WindowPanelHelper from 'helpers/site/window-panel-helper'; +import ModalBarProvider from 'helpers/site/modal-bar-provider'; class NotificationBadgeView extends View { @@ -96,6 +97,13 @@ class NotificationBadgeView extends View { @inject(WebSocketManager) webSocketManager + /** + * @private + * @type {ModalBarProvider} + */ + @inject(ModalBarProvider) + modalBarProvider + setup() { this.addActionHandler('showNotifications', () => this.showNotifications()); @@ -134,30 +142,14 @@ class NotificationBadgeView extends View { delete localStorage['messageBlockPlayNotificationSound']; delete localStorage['messageClosePopupNotificationId']; delete localStorage['messageNotificationRead']; + delete localStorage['messageCollapsePopupNotificationId']; + delete localStorage['messageExpandPopupNotificationId']; - window.addEventListener('storage', e => { - if (e.key === 'messageClosePopupNotificationId') { - const id = localStorage.getItem('messageClosePopupNotificationId'); + const onWindowStorageBind = this.onWindowStorage.bind(this); - if (id) { - const key = 'popup-' + id; + window.addEventListener('storage', onWindowStorageBind, false); - if (this.hasView(key)) { - this.markPopupRemoved(id); - this.clearView(key); - } - } - } - - if (e.key === 'messageNotificationRead') { - if ( - !this.isBroadcastingNotificationRead && - localStorage.getItem('messageNotificationRead') - ) { - this.checkUpdates(); - } - } - }, false); + this.on('remove', () => window.removeEventListener('storage', onWindowStorageBind)) } afterRender() { @@ -187,6 +179,66 @@ class NotificationBadgeView extends View { } } + /** + * @private + * @param {StorageEvent} e + */ + onWindowStorage(e) { + if (e.key === 'messageClosePopupNotificationId') { + const id = localStorage.getItem(e.key); + + this.closePopupNotification(id); + + delete localStorage[e.key]; + } + + if (e.key === 'messageCollapsePopupNotificationId') { + const id = localStorage.getItem(e.key); + + this.collapsePopupNotification(id, true); + + delete localStorage[e.key]; + } + + if (e.key === 'messageExpandPopupNotificationId') { + const id = localStorage.getItem(e.key); + + this.expandPopupNotification(id, true); + + delete localStorage[e.key]; + } + + if (e.key === 'messageNotificationRead') { + if ( + !this.isBroadcastingNotificationRead && + localStorage.getItem('messageNotificationRead') + ) { + this.checkUpdates(); + } + } + } + + /** + * @private + * @param {string} id + */ + closePopupNotification(id) { + if (!id) { + return; + } + + const key = `popup-${id}`; + const view = this.getPopupNotificationView(id); + + if (!view) { + return; + } + + this.modalBarProvider?.get().removeModalView(view); + this.markPopupRemoved(id); + this.clearView(key); + } + playSound() { if (this.notificationSoundsDisabled) { return; @@ -469,20 +521,28 @@ class NotificationBadgeView extends View { }); } - showPopupNotification(name, data, isNotFirstCheck) { - const view = this.popupNotificationsData[name].view; + /** + * @private + * @param {string} name + * @param {Record} data + * @param {boolean} isNotFirstCheck + */ + async showPopupNotification(name, data, isNotFirstCheck = false) { + const viewName = this.popupNotificationsData[name].view; - if (!view) { + if (!viewName) { return; } - let id = data.id || null; + let id; - if (id) { - id = name + '_' + id; + const notificationId = data.id || null; - if (~this.shownNotificationIds.indexOf(id)) { - const notificationView = this.getView('popup-' + id); + if (notificationId) { + id = name + '_' + notificationId; + + if (this.shownNotificationIds.includes(id)) { + const notificationView = this.getPopupNotificationView(id); if (notificationView) { notificationView.trigger('update-data', data.data); @@ -491,34 +551,127 @@ class NotificationBadgeView extends View { return; } - if (~this.closedNotificationIds.indexOf(id)) { + if (this.closedNotificationIds.includes(notificationId)) { return; } - } - else { + } else { id = this.lastId++; } this.shownNotificationIds.push(id); - this.createView('popup-' + id, view, { - notificationData: data.data || {}, - notificationId: data.id, - id: id, - isFirstCheck: !isNotFirstCheck, - }, view => { - view.render(); + /** @type {import('views/popup-notification').default} */ + let view; - this.$popupContainer.removeClass('hidden'); - - this.listenTo(view, 'remove', () => { - this.markPopupRemoved(id); - - localStorage.setItem('messageClosePopupNotificationId', id); + view = /** @type {import('views/popup-notification').default} */ + await this.createView(`popup-${id}`, viewName, { + notificationData: data.data ?? {}, + notificationId: data.id, + id: id, + isFirstCheck: !isNotFirstCheck, + onCollapse: () => { + this.collapsePopupNotification(id); + }, + onExpand: () => { + this.expandPopupNotification(id); + }, }); + + this.$popupContainer.removeClass('hidden'); + + this.listenTo(view, 'remove', () => { + this.markPopupRemoved(id); + + localStorage.setItem('messageClosePopupNotificationId', id); }); + + if (data.id && this.getStorage().get('state', this.getCollapsedStorageKey(id))) { + this.collapsePopupNotification(id, true); + + return; + } + + await view.render(); } + /** + * @private + * @param {string} id + * @param {boolean} silent + */ + collapsePopupNotification(id, silent = false) { + const view = this.getPopupNotificationView(id); + + if (!view) { + return; + } + + if (!silent || !view.isCollapsed) { + this.modalBarProvider.get()?.addModalView(view, { + title: view.getTitle() ?? this.translate('Notification'), + }); + } + + if (silent) { + view.makeCollapsed(); + + return; + } + + localStorage.setItem('messageCollapsePopupNotificationId', id); + + this.getStorage().set('state', this.getCollapsedStorageKey(id), true); + } + + /** + * @private + * @param {string} id + * @param {boolean} silent + */ + expandPopupNotification(id, silent = false) { + const view = this.getPopupNotificationView(id); + + if (!view) { + return; + } + + if (!silent || view.isCollapsed) { + this.modalBarProvider.get()?.removeModalView(view); + } + + if (silent) { + view.makeExpanded(); + + return; + } + + localStorage.setItem('messageExpandPopupNotificationId', id); + + this.getStorage().clear('state', this.getCollapsedStorageKey(id)); + } + + /** + * @private + * @param {string} id + * @return {string} + */ + getCollapsedStorageKey(id) { + return `popupNotificationCollapsed-${id}`; + } + + /** + * @private + * @param {string} id + * @return {import('views/popup-notification').default} + */ + getPopupNotificationView(id) { + return this.getView(`popup-${id}`); + } + + /** + * @private + * @param {string} id + */ markPopupRemoved(id) { const index = this.shownNotificationIds.indexOf(id); diff --git a/client/src/views/popup-notification.js b/client/src/views/popup-notification.js index 3f2ec28101..2c452beb3f 100644 --- a/client/src/views/popup-notification.js +++ b/client/src/views/popup-notification.js @@ -38,14 +38,50 @@ class PopupNotificationView extends View { type = 'default' style = 'default' + + /** + * @protected + * @type {boolean} + */ closeButton = true + + + /** + * @protected + * @type {boolean} + * @since 10.0 + */ + collapseButton = true + + /** + * @type {boolean} + * @internal + */ + isCollapsed = false + soundPath = 'client/sounds/pop_cork' + /** + * @param {{ + * id: string, + * notificationData: Record, + * notificationId: string|null, + * isFirstCheck: boolean, + * onCollapse: function(), + * onExpand: function(), + * }} options + */ + constructor(options) { + super(options); + + this.options = options; + } + init() { super.init(); const id = this.options.id; - const containerSelector = this.containerSelector = '#' + id; + const containerSelector = this.containerSelector = `#${id}`; this.setSelector(containerSelector); @@ -55,9 +91,11 @@ class PopupNotificationView extends View { (this.getConfig().get('popupNotificationSound') || this.soundPath); this.on('render', () => { - this.element = undefined; + this.hide(); - $(containerSelector).remove(); + if (this.isCollapsed) { + return; + } const className = 'popup-notification-' + Espo.Utils.toDom(this.type); @@ -88,6 +126,12 @@ class PopupNotificationView extends View { this.notificationData = this.options.notificationData; this.notificationId = this.options.notificationId; this.id = this.options.id; + + if (!this.notificationId) { + this.collapseButton = false; + } + + this.addActionHandler('collapse', () => this.collapse()); } data() { @@ -95,9 +139,20 @@ class PopupNotificationView extends View { closeButton: this.closeButton, notificationData: this.notificationData, notificationId: this.notificationId, + collapseButton: true, }; } + /** + * @internal + * @since 10.0 + */ + hide() { + this.element = undefined; + + $(this.containerSelector).remove(); + } + playSound() { if (this.notificationSoundsDisabled) { return; @@ -170,6 +225,68 @@ class PopupNotificationView extends View { this.resolveCancel(); } + + /** + * Collapse. + * + * @since 10.0 + * @private + */ + collapse() { + this.isCollapsed = true; + + this.options.onCollapse(); + + this.hide(); + } + + /** + * Expand. + * + * @since 10.0 + * @internal + */ + expand() { + this.isCollapsed = false; + + this.options.onExpand(); + + this.reRender(true); + } + + /** + * Collapse silently. + * + * @since 10.0 + * @internal + */ + makeCollapsed() { + this.isCollapsed = true; + + this.hide(); + } + + /** + * Expand silently. + * + * @since 10.0 + * @internal + */ + makeExpanded() { + this.isCollapsed = false; + + this.reRender(true); + } + + /** + * Get title. + * + * @return string|null + * @since 10.0 + */ + getTitle() { + return null; + } } export default PopupNotificationView; diff --git a/frontend/less/espo/elements/popup-notification.less b/frontend/less/espo/elements/popup-notification.less index a501e26129..7f462f9d29 100644 --- a/frontend/less/espo/elements/popup-notification.less +++ b/frontend/less/espo/elements/popup-notification.less @@ -3,7 +3,7 @@ overflow-x: hidden; // Some space to fix the scrollbar gutter issue https://github.com/w3c/csswg-drafts/issues/9904. right: var(--12px); - bottom: 0; + bottom: var(--24px); position: fixed; height: auto; z-index: 1000; @@ -38,6 +38,11 @@ margin-top: var(--minus-8px); margin-right: var(--minus-3px); } + + a[data-action="collapse"] { + margin-top: var(--minus-6px); + margin-right: var(--12px); + } } .popup-notification-default {