F/collapse notification (#3644)

* dev

* Dev

* Dev

* Dev
This commit is contained in:
Yurii Kuznietsov
2026-04-16 18:59:00 +03:00
committed by GitHub
parent 2da7102535
commit 2c0d5664dd
8 changed files with 402 additions and 58 deletions

View File

@@ -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.",

View File

@@ -1,6 +1,23 @@
{{#if closeButton}}
<a role="button" tabindex="0" class="pull-right close" data-action="close" aria-hidden="true"><span class="fas fa-times"></span></a>
{{/if}}
{{#if closeButton~}}
<a
role="button"
tabindex="0"
class="pull-right close"
data-action="close"
aria-hidden="true"
title="{{translate 'Close'}}"
><span class="fas fa-times"></span></a>
{{~/if~}}
{{#if collapseButton~}}
<a
role="button"
tabindex="0"
class="pull-right text-muted"
data-action="collapse"
aria-hidden="true"
title="{{translate 'Collapse'}}"
><span class="fas fa-minus"></span></a>
{{~/if}}
<h4>{{header}}</h4>

View File

@@ -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;

View File

@@ -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<import('view').default, number>}
*/
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;

View File

@@ -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,

View File

@@ -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);

View File

@@ -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;

View File

@@ -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 {