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 {