Browse Source

Merge remote-tracking branch 'origin/feature/notification-system' into feature/notification-system

pull/7980/head
ViacheslavKlimov 4 years ago
parent
commit
43305ed2b4
  1. 7
      application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java
  2. 4
      ui-ngx/src/app/core/http/notification.service.ts
  3. 336
      ui-ngx/src/app/core/ws/notification-websocket.service.ts
  4. 20
      ui-ngx/src/app/modules/home/components/notification/notification-bell.component.html
  5. 86
      ui-ngx/src/app/modules/home/components/notification/notification-bell.component.ts
  6. 43
      ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.html
  7. 93
      ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.ts
  8. 1
      ui-ngx/src/app/modules/home/home.component.html
  9. 6
      ui-ngx/src/app/modules/home/home.module.ts
  10. 23
      ui-ngx/src/app/modules/home/pages/notification-center/notification-center.component.html
  11. 5
      ui-ngx/src/app/modules/home/pages/notification-center/notification-center.component.ts
  12. 14
      ui-ngx/src/app/modules/home/pages/notification-center/notification-table/inbox-table-config.ts
  13. 16
      ui-ngx/src/app/modules/home/pages/notification-center/notification-table/request-table-config.ts
  14. 13
      ui-ngx/src/app/modules/home/pages/notification-center/request-table/request-notification-dialog.component.html
  15. 21
      ui-ngx/src/app/modules/home/pages/notification-center/request-table/request-notification-dialog.component.scss
  16. 9
      ui-ngx/src/app/modules/home/pages/notification-center/request-table/request-notification-dialog.componet.ts
  17. 38
      ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-notification-dialog.component.html
  18. 14
      ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-notification-dialog.component.scss
  19. 39
      ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-notification-dialog.component.ts
  20. 37
      ui-ngx/src/app/shared/components/notification/notification.component.html
  21. 58
      ui-ngx/src/app/shared/components/notification/notification.component.scss
  22. 88
      ui-ngx/src/app/shared/components/notification/notification.component.ts
  23. 265
      ui-ngx/src/app/shared/models/notification-ws.models.ts
  24. 19
      ui-ngx/src/app/shared/models/notification.models.ts
  25. 1
      ui-ngx/src/app/shared/models/public-api.ts
  26. 4
      ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts
  27. 57
      ui-ngx/src/app/shared/pipe/date-ago.pipe.ts
  28. 1
      ui-ngx/src/app/shared/pipe/public-api.ts
  29. 14
      ui-ngx/src/app/shared/shared.module.ts
  30. 18
      ui-ngx/src/assets/locale/locale.constant-en_US.json

7
application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java

@ -495,9 +495,4 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke
.map(TenantProfile::getDefaultProfileConfiguration).orElse(null);
}
private DefaultTenantProfileConfiguration getTenantProfileConfiguration(WebSocketSessionRef sessionRef) {
return Optional.ofNullable(tenantProfileCache.get(sessionRef.getSecurityCtx().getTenantId()))
.map(TenantProfile::getDefaultProfileConfiguration).orElse(null);
}
}
}

4
ui-ngx/src/app/core/http/notification.service.ts

@ -53,6 +53,10 @@ export class NotificationService {
return this.http.put<void>(`/api/notification/${id}/read`, defaultHttpOptionsFromConfig(config));
}
public markAllNotificationsAsRead(config?: RequestConfig): Observable<void> {
return this.http.put<void>('/api/notifications/read', defaultHttpOptionsFromConfig(config));
}
public createNotificationRequest(notification: NotificationRequest, config?: RequestConfig): Observable<NotificationRequest> {
return this.http.post<NotificationRequest>('/api/notification/request', notification, defaultHttpOptionsFromConfig(config));
}

336
ui-ngx/src/app/core/ws/notification-websocket.service.ts

@ -0,0 +1,336 @@
///
/// Copyright © 2016-2022 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Inject, Injectable, NgZone } from '@angular/core';
import { select, Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { AuthService } from '@core/auth/auth.service';
import { selectIsAuthenticated } from '@core/auth/auth.selectors';
import { WINDOW } from '@core/services/window.service';
import { webSocket, WebSocketSubject } from 'rxjs/webSocket';
import { ActionNotificationShow } from '@core/notification/notification.actions';
import {
isNotificationCountUpdateMsg,
isNotificationsUpdateMsg,
MarkAllAsReadCmd,
MarkAsReadCmd,
NotificationCountUpdate,
NotificationPluginCmdsWrapper,
NotificationSubscriber,
NotificationsUpdate,
NotificationWsService,
UnreadCountSubCmd,
UnreadSubCmd,
UnsubscribeCmd,
WebsocketNotificationMsg
} from '@shared/models/notification-ws.models';
import Timeout = NodeJS.Timeout;
const RECONNECT_INTERVAL = 2000;
const WS_IDLE_TIMEOUT = 90000;
// @dynamic
@Injectable({
providedIn: 'root'
})
export class NotificationWebsocketService implements NotificationWsService {
isActive = false;
isOpening = false;
isOpened = false;
isReconnect = false;
socketCloseTimer: Timeout;
reconnectTimer: Timeout;
lastCmdId = 0;
subscribersCount = 0;
subscribersMap = new Map<number, NotificationSubscriber>();
reconnectSubscribers = new Set<NotificationSubscriber>();
cmdsWrapper = new NotificationPluginCmdsWrapper();
notificationUri: string;
dataStream: WebSocketSubject<NotificationPluginCmdsWrapper | WebsocketNotificationMsg>;
constructor(private store: Store<AppState>,
private authService: AuthService,
private ngZone: NgZone,
@Inject(WINDOW) private window: Window) {
this.store.pipe(select(selectIsAuthenticated)).subscribe(
() => {
this.reset(true);
}
);
let port = this.window.location.port;
if (this.window.location.protocol === 'https:') {
if (!port) {
port = '443';
}
this.notificationUri = 'wss:';
} else {
if (!port) {
port = '80';
}
this.notificationUri = 'ws:';
}
this.notificationUri += `//${this.window.location.hostname}:${port}/api/ws/plugins/notifications`;
}
public subscribe(subscriber: NotificationSubscriber) {
this.isActive = true;
subscriber.subscriptionCommands.forEach(
(subscriptionCommand) => {
const cmdId = this.nextCmdId();
this.subscribersMap.set(cmdId, subscriber);
subscriptionCommand.cmdId = cmdId;
if (subscriptionCommand instanceof UnreadCountSubCmd) {
this.cmdsWrapper.unreadCountSubCmd = subscriptionCommand;
} else if (subscriptionCommand instanceof UnreadSubCmd) {
this.cmdsWrapper.unreadSubCmd = subscriptionCommand;
} else if (subscriptionCommand instanceof MarkAsReadCmd) {
this.cmdsWrapper.markAsReadCmd = subscriptionCommand;
this.subscribersMap.delete(cmdId);
} else if (subscriptionCommand instanceof MarkAllAsReadCmd) {
this.cmdsWrapper.markAllAsReadCmd = subscriptionCommand;
this.subscribersMap.delete(cmdId);
}
}
);
if (this.cmdsWrapper.markAsReadCmd || this.cmdsWrapper.markAllAsReadCmd) {
this.subscribersCount++;
}
this.publishCommands();
}
public update(subscriber: NotificationSubscriber) {
if (!this.isReconnect) {
subscriber.subscriptionCommands.forEach(
(subscriptionCommand) => {
if (subscriptionCommand.cmdId && subscriptionCommand instanceof UnreadSubCmd) {
this.cmdsWrapper.unreadSubCmd = subscriptionCommand;
}
}
);
this.publishCommands();
}
}
public unsubscribe(subscriber: NotificationSubscriber) {
if (this.isActive) {
subscriber.subscriptionCommands.forEach(
(subscriptionCommand) => {
if (subscriptionCommand instanceof UnreadCountSubCmd
|| subscriptionCommand instanceof UnreadSubCmd) {
const unreadCountUnsubscribeCmd = new UnsubscribeCmd();
unreadCountUnsubscribeCmd.cmdId = subscriptionCommand.cmdId;
this.cmdsWrapper.unsubCmd = unreadCountUnsubscribeCmd;
}
const cmdId = subscriptionCommand.cmdId;
if (cmdId) {
this.subscribersMap.delete(cmdId);
}
}
);
this.reconnectSubscribers.delete(subscriber);
this.subscribersCount--;
this.publishCommands();
}
}
private nextCmdId(): number {
this.lastCmdId++;
return this.lastCmdId;
}
private publishCommands() {
while (this.isOpened && this.cmdsWrapper.hasCommands()) {
this.dataStream.next(this.cmdsWrapper.preparePublishCommands());
this.cmdsWrapper.clear();
this.checkToClose();
}
this.tryOpenSocket();
}
private checkToClose() {
if (this.subscribersCount === 0 && this.isOpened) {
if (!this.socketCloseTimer) {
this.socketCloseTimer = setTimeout(
() => this.closeSocket(), WS_IDLE_TIMEOUT);
}
}
}
private reset(close: boolean) {
if (this.socketCloseTimer) {
clearTimeout(this.socketCloseTimer);
this.socketCloseTimer = null;
}
this.lastCmdId = 0;
this.subscribersMap.clear();
this.subscribersCount = 0;
this.cmdsWrapper.clear();
if (close) {
this.closeSocket();
}
}
private closeSocket() {
this.isActive = false;
if (this.isOpened) {
this.dataStream.unsubscribe();
}
}
private tryOpenSocket() {
if (this.isActive) {
if (!this.isOpened && !this.isOpening) {
this.isOpening = true;
if (AuthService.isJwtTokenValid()) {
this.openSocket(AuthService.getJwtToken());
} else {
this.authService.refreshJwtToken().subscribe(() => {
this.openSocket(AuthService.getJwtToken());
},
() => {
this.isOpening = false;
this.authService.logout(true, true);
}
);
}
}
if (this.socketCloseTimer) {
clearTimeout(this.socketCloseTimer);
this.socketCloseTimer = null;
}
}
}
private openSocket(token: string) {
const uri = `${this.notificationUri}?token=${token}`;
this.dataStream = webSocket(
{
url: uri,
openObserver: {
next: () => {
this.onOpen();
}
},
closeObserver: {
next: (e: CloseEvent) => {
this.onClose(e);
}
}
}
);
this.dataStream.subscribe((message) => {
this.ngZone.runOutsideAngular(() => {
this.onMessage(message as WebsocketNotificationMsg);
});
},
(error) => {
this.onError(error);
});
}
private onOpen() {
this.isOpening = false;
this.isOpened = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.isReconnect) {
this.isReconnect = false;
this.reconnectSubscribers.forEach(
(reconnectSubscriber) => {
reconnectSubscriber.onReconnected();
this.subscribe(reconnectSubscriber);
}
);
this.reconnectSubscribers.clear();
} else {
this.publishCommands();
}
}
private onMessage(message: WebsocketNotificationMsg) {
if (message.errorCode) {
this.showWsError(message.errorCode, message.errorMsg);
} else {
let subscriber: NotificationSubscriber;
if (isNotificationCountUpdateMsg(message)) {
subscriber = this.subscribersMap.get(message.cmdId);
if (subscriber) {
subscriber.onNotificationCountUpdate(new NotificationCountUpdate(message));
}
} else if (isNotificationsUpdateMsg(message)) {
subscriber = this.subscribersMap.get(message.cmdId);
if (subscriber) {
subscriber.onNotificationsUpdate(new NotificationsUpdate(message));
}
}
}
this.checkToClose();
}
private onError(errorEvent) {
if (errorEvent) {
console.warn('WebSocket error event', errorEvent);
}
this.isOpening = false;
}
private onClose(closeEvent: CloseEvent) {
if (closeEvent && closeEvent.code > 1001 && closeEvent.code !== 1006
&& closeEvent.code !== 1011 && closeEvent.code !== 1012 && closeEvent.code !== 4500) {
this.showWsError(closeEvent.code, closeEvent.reason);
}
this.isOpening = false;
this.isOpened = false;
if (this.isActive) {
if (!this.isReconnect) {
this.reconnectSubscribers.clear();
this.subscribersMap.forEach(
(subscriber) => {
this.reconnectSubscribers.add(subscriber);
}
);
this.reset(false);
this.isReconnect = true;
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
this.reconnectTimer = setTimeout(() => this.tryOpenSocket(), RECONNECT_INTERVAL);
}
}
private showWsError(errorCode: number, errorMsg: string) {
let message = errorMsg;
if (!message) {
message += `WebSocket Notification Error: error code - ${errorCode}.`;
}
this.store.dispatch(new ActionNotificationShow(
{
message, type: 'error'
}));
}
}

20
ui-ngx/src/app/modules/home/components/notification/notification-bell.component.html

@ -0,0 +1,20 @@
<!--
Copyright © 2016-2022 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<button mat-icon-button #showNotificationButton (click)="showNotification($event, showNotificationButton)">
<mat-icon class="material-icons" [matBadge]="(count$ | async)" [matBadgeHidden]="!(count$ | async)" matBadgeColor="warn" matBadgeSize="small">notifications</mat-icon>
</button>

86
ui-ngx/src/app/modules/home/components/notification/notification-bell.component.ts

@ -0,0 +1,86 @@
///
/// Copyright © 2016-2022 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
NgZone,
OnInit,
Renderer2,
ViewContainerRef
} from '@angular/core';
import { NotificationWebsocketService } from '@core/ws/notification-websocket.service';
import { Observable } from 'rxjs';
import { distinctUntilChanged, publishReplay, refCount, tap } from 'rxjs/operators';
import { MatButton } from '@angular/material/button';
import { TbPopoverService } from '@shared/components/popover.service';
import { ShowNotificationPopoverComponent } from '@home/components/notification/show-notification-popover.component';
import { NotificationSubscriber } from '@shared/models/notification-ws.models';
@Component({
selector: 'tb-notification-bell',
templateUrl: './notification-bell.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class NotificationBellComponent implements OnInit {
private notificationSubscriber: NotificationSubscriber;
count$: Observable<number>;
constructor(
private notificationWsService: NotificationWebsocketService,
private zone: NgZone,
private cd: ChangeDetectorRef,
private popoverService: TbPopoverService,
private renderer: Renderer2,
private viewContainerRef: ViewContainerRef) {
}
ngOnInit() {
this.notificationSubscriber = NotificationSubscriber.createNotificationCountSubscription(
this.notificationWsService, this.zone);
this.notificationSubscriber.subscribe();
this.count$ = this.notificationSubscriber.notificationCount$.pipe(
distinctUntilChanged(),
publishReplay(1),
refCount(),
tap(() => setTimeout(() => this.cd.markForCheck())),
);
}
showNotification($event: Event, createVersionButton: MatButton) {
if ($event) {
$event.stopPropagation();
}
const trigger = createVersionButton._elementRef.nativeElement;
if (this.popoverService.hasPopover(trigger)) {
this.popoverService.hidePopover(trigger);
} else {
const showNotificationPopover = this.popoverService.displayPopover(trigger, this.renderer,
this.viewContainerRef, ShowNotificationPopoverComponent, 'bottom', true, null,
{
onClose: () => {
showNotificationPopover.hide();
}
},
{maxHeight: '100vh', height: '100%', padding: '10px'},
{width: '400px', minWidth: '100%', maxWidth: '100%'},
{height: '100%', flexDirection: 'column', boxSizing: 'border-box', display: 'flex'}, false);
showNotificationPopover.tbComponentRef.instance.popoverComponent = showNotificationPopover;
}
}
}

43
ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.html

@ -0,0 +1,43 @@
<!--
Copyright © 2016-2022 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<section fxLayout="row" fxLayoutAlign="space-between center" style="margin-bottom: 4px">
<div style="font-weight: 500; letter-spacing: .25px" translate>notification.notification</div>
<button mat-button color="primary"
[disabled]="!(notifications$ | async).length"
(click)="markAsAllRead($event)">
{{ 'notification.mark-all-as-read' | translate }}
</button>
</section>
<mat-divider></mat-divider>
<section style="min-height: 100px; overflow: auto">
<div *ngFor="let notification of (notifications$ | async); let last = last">
<tb-notification [notification]="notification"
[onClose]="onClose"
(markAsRead)="markAsRead($event)">
</tb-notification>
<mat-divider *ngIf="!last" style="margin-bottom: 4px"></mat-divider>
</div>
</section>
<div *ngIf="(notifications$ | async).length">
<mat-divider></mat-divider>
<section fxLayoutAlign="center center">
<button fxFlex mat-button color="primary" (click)="viewAll($event)">
{{ 'notification.view-all' | translate }}
</button>
</section>
</div>

93
ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.ts

@ -0,0 +1,93 @@
///
/// Copyright © 2016-2022 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { ChangeDetectorRef, Component, Input, NgZone, OnDestroy, OnInit } from '@angular/core';
import { PageComponent } from '@shared/components/page.component';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Notification } from '@shared/models/notification.models';
import { NotificationWebsocketService } from '@core/ws/notification-websocket.service';
import { Observable } from 'rxjs';
import { publishReplay, refCount, tap } from 'rxjs/operators';
import { Router } from '@angular/router';
import { NotificationSubscriber } from '@shared/models/notification-ws.models';
@Component({
selector: 'tb-show-notification-popover',
templateUrl: './show-notification-popover.component.html',
styleUrls: []
})
export class ShowNotificationPopoverComponent extends PageComponent implements OnDestroy, OnInit {
@Input()
onClose: () => void;
@Input()
popoverComponent: TbPopoverComponent;
private notificationSubscriber: NotificationSubscriber;
notifications$: Observable<Notification[]>;
constructor(protected store: Store<AppState>,
private notificationWsService: NotificationWebsocketService,
private zone: NgZone,
private cd: ChangeDetectorRef,
private router: Router) {
super(store);
}
ngOnInit() {
this.notificationSubscriber = NotificationSubscriber.createNotificationsSubscription(
this.notificationWsService, this.zone);
this.notifications$ = this.notificationSubscriber.notifications$.pipe(
publishReplay(1),
refCount(),
tap(() => {
setTimeout(() => {
this.cd.markForCheck();
});
})
);
this.notificationSubscriber.subscribe();
}
ngOnDestroy() {
super.ngOnDestroy();
this.notificationSubscriber.unsubscribe();
}
markAsRead(id: string) {
const cmd = NotificationSubscriber.createMarkAsReadCommand(this.notificationWsService, [id]);
cmd.subscribe();
}
markAsAllRead($event: Event) {
if ($event) {
$event.stopPropagation();
}
const cmd = NotificationSubscriber.createMarkAllAsReadCommand(this.notificationWsService);
cmd.subscribe();
}
viewAll($event: Event) {
if ($event) {
$event.stopPropagation();
}
this.onClose();
this.router.navigateByUrl(this.router.createUrlTree(['notification-center'])).then(() => {});
}
}

1
ui-ngx/src/app/modules/home/home.component.html

@ -70,6 +70,7 @@
mat-icon-button fxHide.lt-md (click)="toggleFullscreen()">
<mat-icon class="material-icons">{{ isFullscreen() ? 'fullscreen_exit' : 'fullscreen' }}</mat-icon>
</button>
<tb-notification-bell></tb-notification-bell>
<tb-user-menu [displayUserInfo]="!displaySearchMode()"></tb-user-menu>
</mat-toolbar>
<mat-progress-bar color="warn" style="z-index: 10; margin-bottom: -4px; width: 100%;" mode="indeterminate"

6
ui-ngx/src/app/modules/home/home.module.ts

@ -23,6 +23,8 @@ import { SharedModule } from '@app/shared/shared.module';
import { MenuLinkComponent } from '@modules/home/menu/menu-link.component';
import { MenuToggleComponent } from '@modules/home/menu/menu-toggle.component';
import { SideMenuComponent } from '@modules/home/menu/side-menu.component';
import { NotificationBellComponent } from '@home/components/notification/notification-bell.component';
import { ShowNotificationPopoverComponent } from '@home/components/notification/show-notification-popover.component';
@NgModule({
declarations:
@ -30,7 +32,9 @@ import { SideMenuComponent } from '@modules/home/menu/side-menu.component';
HomeComponent,
MenuLinkComponent,
MenuToggleComponent,
SideMenuComponent
SideMenuComponent,
NotificationBellComponent,
ShowNotificationPopoverComponent
],
imports: [
CommonModule,

23
ui-ngx/src/app/modules/home/pages/notification-center/notification-center.component.html

@ -17,40 +17,25 @@
-->
<div class="tb-notification-center">
<div class="tb-notification-center-content">
<!-- <section fxLayout="row" fxLayoutAlign="space-between center" class="title-container">-->
<!-- <h2 translate class="title">notification.notification-center</h2>-->
<!-- <section class="action-buttons" fxLayoutAlign="end center">-->
<!-- <button mat-icon-button [disabled]="isLoading$ | async" (click)="openSetting()"-->
<!-- matTooltip="{{ 'notification.settings' | translate }}"-->
<!-- matTooltipPosition="above">-->
<!-- <mat-icon>settings</mat-icon>-->
<!-- </button>-->
<!-- <button mat-icon-button [disabled]="isLoading$ | async" (click)="updateData()"-->
<!-- matTooltip="{{ 'action.refresh' | translate }}"-->
<!-- matTooltipPosition="above">-->
<!-- <mat-icon>refresh</mat-icon>-->
<!-- </button>-->
<!-- </section>-->
<!-- </section>-->
<div class="tabs-container">
<mat-tab-group #matTabGroup (selectedIndexChange)="updateData()">
<mat-tab label="{{ 'notification.inbox' | translate }}" #inboxTab="matTab">
<mat-tab label="{{ 'notification.inbox' | translate }}">
<tb-notification-table
[notificationType]="entityType.NOTIFICATION">
</tb-notification-table>
</mat-tab>
<mat-tab label="{{ 'notification.sent' | translate }}">
<mat-tab label="{{ 'notification.sent' | translate }}" #requestTab="matTab">
<tb-notification-table
#notificationRequest
[notificationType]="entityType.NOTIFICATION_REQUEST">
</tb-notification-table>
</mat-tab>
<mat-tab label="{{ 'notification.templates' | translate }}" #templateTab="matTab">
<mat-tab label="{{ 'notification.templates' | translate }}">
<tb-notification-table
[notificationType]="entityType.NOTIFICATION_TEMPLATE">
</tb-notification-table>
</mat-tab>
<mat-tab label="{{ 'notification.targets' | translate }}" #targetsTab="matTab">
<mat-tab label="{{ 'notification.targets' | translate }}">
<tb-notification-table
[notificationType]="entityType.NOTIFICATION_TARGET">
</tb-notification-table>

5
ui-ngx/src/app/modules/home/pages/notification-center/notification-center.component.ts

@ -18,7 +18,7 @@ import { Component, QueryList, ViewChild, ViewChildren } from '@angular/core';
import { PageComponent } from '@shared/components/page.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { MatTabGroup } from '@angular/material/tabs';
import { MatTab, MatTabGroup } from '@angular/material/tabs';
import {
NotificationTableComponent
} from '@home/pages/notification-center/notification-table/notification-table.component';
@ -34,6 +34,7 @@ export class NotificationCenterComponent extends PageComponent {
entityType = EntityType;
@ViewChild('matTabGroup', {static: true}) matTabs: MatTabGroup;
@ViewChild('requestTab', {static: true}) requestTab: MatTab;
@ViewChild('notificationRequest', {static: true}) notificationRequestTable: NotificationTableComponent;
@ViewChildren(NotificationTableComponent) tableComponent: QueryList<NotificationTableComponent>;
@ -56,6 +57,6 @@ export class NotificationCenterComponent extends PageComponent {
}
sendNotification($event: Event) {
this.notificationRequestTable.entityTableConfig.onEntityAction({event: $event, action: 'add', entity: null});
this.notificationRequestTable.entityTableConfig.onEntityAction({event: $event, action: this.requestTab.isActive ? 'add' : 'add-without-update', entity: null});
}
}

14
ui-ngx/src/app/modules/home/pages/notification-center/notification-table/inbox-table-config.ts

@ -59,7 +59,7 @@ export class InboxTableConfig extends EntityTableConfig<Notification> {
this.headerComponent = InboxTableHeaderComponent;
this.headerActionDescriptors = [{
name: this.translate.instant('notification.mark-all-read'),
name: this.translate.instant('notification.mark-all-as-read'),
icon: 'done_all',
isEnabled: () => true,
onAction: $event => this.markAllRead($event)
@ -88,11 +88,13 @@ export class InboxTableConfig extends EntityTableConfig<Notification> {
if ($event) {
$event.stopPropagation();
}
if (this.componentsData.unreadOnly) {
this.getTable().resetSortAndFilter(true);
} else {
this.updateData();
}
this.notificationService.markAllNotificationsAsRead().subscribe(() => {
if (this.componentsData.unreadOnly) {
this.getTable().resetSortAndFilter(true);
} else {
this.updateData();
}
});
}
private markAsRead($event, entity){

16
ui-ngx/src/app/modules/home/pages/notification-center/notification-table/request-table-config.ts

@ -84,7 +84,7 @@ export class RequestTableConfig extends EntityTableConfig<NotificationRequest, P
return [];
}
private createRequest($event: Event, request: NotificationRequest, isAdd = false) {
private createRequest($event: Event, request: NotificationRequest, isAdd = false, updateData = true) {
if ($event) {
$event.stopPropagation();
}
@ -96,12 +96,11 @@ export class RequestTableConfig extends EntityTableConfig<NotificationRequest, P
isAdd,
request
}
}).afterClosed()
.subscribe((res) => {
if (res) {
this.updateData();
}
});
}).afterClosed().subscribe((res) => {
if (res && updateData) {
this.updateData();
}
});
}
private onRequestAction(action: EntityAction<NotificationRequest>): boolean {
@ -109,6 +108,9 @@ export class RequestTableConfig extends EntityTableConfig<NotificationRequest, P
case 'add':
this.createRequest(action.event, action.entity, true);
return true;
case 'add-without-update':
this.createRequest(action.event, action.entity, true, false);
return true;
}
return false;
}

13
ui-ngx/src/app/modules/home/pages/notification-center/request-table/request-notification-dialog.component.html

@ -57,12 +57,21 @@
strokeWidth="5" *ngIf="(isLoading$ | async) && !preview">
</mat-progress-spinner>
<div *ngIf="preview">
<section class="preview-group notification" *ngIf="preview.processedTemplates.PUSH.enabled">
<div fxLayout="row" fxLayoutGap="8px" fxLayoutAlign="start center">
<mat-icon svgIcon="mdi:bell-badge"></mat-icon>
<div class="group-title">Web notification preview</div>
</div>
<div class="web-preview">
<tb-notification preview [notification]="preview.processedTemplates.PUSH"></tb-notification>
</div>
</section>
<section class="preview-group">
<div fxLayout="row" fxLayoutGap="8px" fxLayoutAlign="start center">
<mat-icon>supervisor_account</mat-icon>
<div class="total-recipients">{{ 'notification.recipients-count' | translate : {count: preview.totalRecipientsCount} }}</div>
<div class="group-title">{{ 'notification.recipients-count' | translate : {count: preview.totalRecipientsCount} }}</div>
</div>
<div class="details-recipients" *ngIf="notificationRequestForm.get('targets').value.length">
<div class="details-recipients" *ngIf="notificationRequestForm.get('targets').value.length > 1">
<div *ngFor="let detail of preview.recipientsCountByTarget | keyvalue" class="details-recipient">
<span class="number">{{ detail.value }}</span>{{ detail.key }}
</div>

21
ui-ngx/src/app/modules/home/pages/notification-center/request-table/request-notification-dialog.component.scss

@ -14,6 +14,7 @@
* limitations under the License.
*/
@import "../../../../../../scss/constants";
@import "../../../../../../theme";
:host-context(.tb-fullscreen-dialog .mat-dialog-container) {
width: 640px;
@ -24,11 +25,15 @@
border: 1px groove rgba(0, 0, 0, .12);
border-radius: 4px;
&.notification {
background-color: #F3F6FA; //map_get($tb-mat-indigo, 50);
}
.mat-icon {
color: rgba(0, 0, 0, 0.38);
}
.total-recipients {
.group-title {
font-weight: 500;
letter-spacing: .25px;
}
@ -51,6 +56,20 @@
margin-bottom: 8px;
}
}
.web-preview {
flex-direction: row;
align-items: center;
justify-content: center;
display: flex;
tb-notification {
border: 1px groove $tb-primary-color;
border-radius: 4px;
width: 80%;
background-color: #fff;
}
}
}
@media #{$mat-gt-xs} {

9
ui-ngx/src/app/modules/home/pages/notification-center/request-table/request-notification-dialog.componet.ts

@ -129,7 +129,14 @@ export class RequestNotificationDialogComponent extends
private getPreview() {
if (this.allValid()) {
this.preview = null;
this.notificationService.getNotificationRequestPreview(this.notificationFormValue).subscribe(
this.notificationService.getNotificationRequestPreview(this.notificationFormValue).pipe(
map(data => {
if (data.processedTemplates.PUSH?.enabled) {
(data.processedTemplates.PUSH as any).text = data.processedTemplates.PUSH.body;
}
return data;
})
).subscribe(
(preview) => this.preview = preview
);
}

38
ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-notification-dialog.component.html

@ -95,6 +95,44 @@
<mat-label translate>notification.message</mat-label>
<input matInput formControlName="body" [placeholder]="templateNotificationForm.get('configuration.defaultTextTemplate').value">
</mat-form-field>
<section formGroupName="additionalConfig">
<section formGroupName="icon" class="additional-config-group">
<mat-slide-toggle formControlName="enabled" class="toggle">
{{ 'icon.icon' | translate }}
</mat-slide-toggle>
<div *ngIf="pushTemplateForm.get('additionalConfig.icon.enabled').value" fxLayout="row" fxLayoutGap="12px">
<tb-material-icon-select formControlName="icon" required fxFlex>
</tb-material-icon-select>
<tb-color-input formControlName="color" fxFlex>
</tb-color-input>
</div>
</section>
<section formGroupName="actionButtonConfig" class="additional-config-group">
<mat-slide-toggle formControlName="enabled" class="toggle">
{{ 'notification.action-button' | translate }}
</mat-slide-toggle>
<div *ngIf="pushTemplateForm.get('additionalConfig.actionButtonConfig.enabled').value">
<div fxLayout="row" fxLayoutGap="12px">
<mat-form-field class="mat-block" fxFlex>
<mat-label translate>notification.button-text</mat-label>
<input matInput formControlName="text" required>
<mat-error *ngIf="pushTemplateForm.get('additionalConfig.actionButtonConfig.text').hasError('required')">
{{ 'notification.button-text-required' | translate }}
</mat-error>
</mat-form-field>
<tb-color-input formControlName="color" fxFlex>
</tb-color-input>
</div>
<mat-form-field class="mat-block">
<mat-label translate>notification.link</mat-label>
<input matInput formControlName="link" required>
<mat-error *ngIf="pushTemplateForm.get('additionalConfig.actionButtonConfig.link').hasError('required')">
{{ 'notification.link-required' | translate }}
</mat-error>
</mat-form-field>
</div>
</section>
</section>
</form>
</mat-step>
<mat-step optional *ngIf="templateNotificationForm.get('configuration.deliveryMethodsTemplates.EMAIL.enabled').value"

14
ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-notification-dialog.component.scss

@ -38,6 +38,19 @@
}
}
.additional-config-group {
padding: 16px 16px 4px;
margin-bottom: 12px;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 6px;
width: 100%;
height: 100%;
.toggle {
margin-bottom: 12px;
}
}
@media #{$mat-gt-xs} {
.mat-dialog-content {
max-height: 75vh;
@ -86,6 +99,7 @@
.mat-radio-group {
display: flex;
flex-direction: row;
margin-bottom: 8px;
.mat-radio-button {
flex: 1 1 100%;

39
ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-notification-dialog.component.ts

@ -32,7 +32,7 @@ import { FormBuilder, FormGroup, ValidationErrors, Validators } from '@angular/f
import { NotificationService } from '@core/http/notification.service';
import { deepTrim, isDefined } from '@core/utils';
import { Observable, Subject } from 'rxjs';
import { map } from 'rxjs/operators';
import { map, takeUntil } from 'rxjs/operators';
import { StepperOrientation, StepperSelectionEvent } from '@angular/cdk/stepper';
import { MatStepper } from '@angular/material/stepper';
import { BreakpointObserver } from '@angular/cdk/layout';
@ -125,8 +125,41 @@ export class TemplateNotificationDialogComponent
this.pushTemplateForm = this.fb.group({
subject: [''],
body: [''],
icon: [''],
actionButtonConfig: ['']
additionalConfig: this.fb.group({
icon: this.fb.group({
enabled: [false],
icon: [{value: '', disabled: true}, Validators.required],
color: ['#757575']
}),
actionButtonConfig: this.fb.group({
enabled: [false],
text: [{value: '', disabled: true}, Validators.required],
color: ['#305680'],
link: [{value: '', disabled: true}, Validators.required]
}),
})
});
this.pushTemplateForm.get('additionalConfig.icon.enabled').valueChanges.pipe(
takeUntil(this.destroy$)
).subscribe((value) => {
if (value) {
this.pushTemplateForm.get('additionalConfig.icon.icon').enable({emitEvent: false});
} else {
this.pushTemplateForm.get('additionalConfig.icon.icon').disable({emitEvent: false});
}
});
this.pushTemplateForm.get('additionalConfig.actionButtonConfig.enabled').valueChanges.pipe(
takeUntil(this.destroy$)
).subscribe((value) => {
if (value) {
this.pushTemplateForm.get('additionalConfig.actionButtonConfig.text').enable({emitEvent: false});
this.pushTemplateForm.get('additionalConfig.actionButtonConfig.link').enable({emitEvent: false});
} else {
this.pushTemplateForm.get('additionalConfig.actionButtonConfig.text').disable({emitEvent: false});
this.pushTemplateForm.get('additionalConfig.actionButtonConfig.link').disable({emitEvent: false});
}
});
this.emailTemplateForm = this.fb.group({

37
ui-ngx/src/app/shared/components/notification/notification.component.html

@ -0,0 +1,37 @@
<!--
Copyright © 2016-2022 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<section fxLayout="row" fxLayoutAlign="space-between start">
<mat-icon class="icon" *ngIf="showIcon" [ngStyle]="{color: notification.additionalConfig.icon.color}">
{{ notification.additionalConfig.icon.icon }}
</mat-icon>
<div class="content" fxFlex>
<div class="title">{{ notification.subject }}</div>
<div class="message">{{ notification.text }}</div>
<button *ngIf="showButton" mat-stroked-button class="button"
[ngStyle]="{color: notification.additionalConfig.actionButtonConfig.color}"
(click)="navigate($event)">
{{ buttonLabel }}
</button>
</div>
<div class="description" fxLayout="column" fxLayoutAlign="start end">
<button mat-icon-button class="tb-mat-28 mark-read" (click)="markRead($event)">
<mat-icon>check_circle_outline</mat-icon>
</button>
<span class="time" *ngIf="preview">{{ notification.createdTime | dateAgo }}</span>
</div>
</section>

58
ui-ngx/src/app/shared/components/notification/notification.component.scss

@ -0,0 +1,58 @@
/**
* Copyright © 2016-2022 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
:host {
padding: 10px;
display: block;
.icon {
margin-right: 14px;
color: rgba(0, 0, 0, 0.54);
}
.content {
letter-spacing: .25px;
.title {
margin-bottom: 4px;
font-weight: 500;
}
.message {
font-size: 14px;
line-height: 16px;
color: rgba(0, 0, 0, 0.76);
}
.button {
margin-top: 8px;
border-color: initial;
}
}
.description {
margin-left: 8px;
min-width: 70px;
color: rgba(0, 0, 0, 0.54);
.mark-read {
margin-bottom: 6px;
}
.time {
font-size: 12px;
letter-spacing: 0.25px;
margin-right: 2px;
}
}
}

88
ui-ngx/src/app/shared/components/notification/notification.component.ts

@ -0,0 +1,88 @@
///
/// Copyright © 2016-2022 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { Notification } from '@shared/models/notification.models';
import { UtilsService } from '@core/services/utils.service';
import { Router } from '@angular/router';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
@Component({
selector: 'tb-notification',
templateUrl: './notification.component.html',
styleUrls: ['./notification.component.scss']
})
export class NotificationComponent implements OnInit {
@Input()
notification: Notification;
@Input()
onClose: () => void;
@Output()
markAsRead = new EventEmitter<string>();
private previewValue = false;
get preview(): boolean {
return this.previewValue;
}
@Input()
set preview(value: boolean) {
this.previewValue = coerceBooleanProperty(value);
}
showIcon = false;
showButton = false;
buttonLabel = '';
constructor(
private utils: UtilsService,
private router: Router
) {
}
ngOnInit() {
this.showIcon = this.notification.additionalConfig?.icon?.enabled;
this.showButton = this.notification.additionalConfig?.actionButtonConfig?.enabled;
if (this.showButton) {
this.buttonLabel = this.utils.customTranslation(this.notification.additionalConfig.actionButtonConfig.text,
this.notification.additionalConfig.actionButtonConfig.text);
}
}
markRead($event: Event) {
if ($event) {
$event.stopPropagation();
}
if (!this.preview) {
this.markAsRead.next(this.notification.id.id);
}
}
navigate($event: Event) {
if ($event) {
$event.stopPropagation();
}
if (!this.preview) {
this.router.navigateByUrl(this.router.parseUrl(this.notification.additionalConfig.actionButtonConfig.link)).then(() => {
});
if (this.onClose) {
this.onClose();
}
}
}
}

265
ui-ngx/src/app/shared/models/notification-ws.models.ts

@ -0,0 +1,265 @@
///
/// Copyright © 2016-2022 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { BehaviorSubject, ReplaySubject, Subject } from 'rxjs';
import { CmdUpdate, CmdUpdateMsg, CmdUpdateType, WebsocketCmd } from '@shared/models/telemetry/telemetry.models';
import { first, map } from 'rxjs/operators';
import { NgZone } from '@angular/core';
import { isDefinedAndNotNull } from '@core/utils';
import { Notification } from '@shared/models/notification.models';
export class NotificationCountUpdate extends CmdUpdate {
totalUnreadCount: number;
constructor(msg: NotificationCountUpdateMsg) {
super(msg);
this.totalUnreadCount = msg.totalUnreadCount;
}
}
export class NotificationsUpdate extends CmdUpdate {
totalUnreadCount: number;
update?: Notification;
notifications?: Notification[];
constructor(msg: NotificationsUpdateMsg) {
super(msg);
this.totalUnreadCount = msg.totalUnreadCount;
this.update = msg.update;
this.notifications = msg.notifications;
}
}
export interface NotificationWsService {
subscribe(subscriber: NotificationSubscriber);
update(subscriber: NotificationSubscriber);
unsubscribe(subscriber: NotificationSubscriber);
}
export class NotificationSubscriber {
private notificationCountSubject = new ReplaySubject<NotificationCountUpdate>(1);
private notificationsSubject = new BehaviorSubject<NotificationsUpdate>(null);
private reconnectSubject = new Subject();
public subscriptionCommands: Array<WebsocketCmd>;
public messageLimit = 10;
public notificationCount$ = this.notificationCountSubject.asObservable().pipe(map(msg => msg.totalUnreadCount));
public notifications$ = this.notificationsSubject.asObservable().pipe(map(msg => msg?.notifications || []));
public static createNotificationCountSubscription(notificationWsService: NotificationWsService,
zone: NgZone): NotificationSubscriber {
const subscriptionCommand = new UnreadCountSubCmd();
const subscriber = new NotificationSubscriber(notificationWsService, zone);
subscriber.subscriptionCommands.push(subscriptionCommand);
return subscriber;
}
public static createNotificationsSubscription(notificationWsService: NotificationWsService,
zone: NgZone, limit = 10): NotificationSubscriber {
const subscriptionCommand = new UnreadSubCmd(limit);
const subscriber = new NotificationSubscriber(notificationWsService, zone);
subscriber.messageLimit = limit;
subscriber.subscriptionCommands.push(subscriptionCommand);
return subscriber;
}
public static createMarkAsReadCommand(notificationWsService: NotificationWsService,
ids: string[]): NotificationSubscriber {
const subscriptionCommand = new MarkAsReadCmd(ids);
const subscriber = new NotificationSubscriber(notificationWsService);
subscriber.subscriptionCommands.push(subscriptionCommand);
return subscriber;
}
public static createMarkAllAsReadCommand(notificationWsService: NotificationWsService): NotificationSubscriber {
const subscriptionCommand = new MarkAllAsReadCmd();
const subscriber = new NotificationSubscriber(notificationWsService);
subscriber.subscriptionCommands.push(subscriptionCommand);
return subscriber;
}
constructor(private notificationWsService: NotificationWsService, private zone?: NgZone) {
this.subscriptionCommands = [];
}
public subscribe() {
this.notificationWsService.subscribe(this);
}
public update() {
this.notificationWsService.update(this);
}
public unsubscribe() {
this.notificationWsService.unsubscribe(this);
this.complete();
}
public onReconnected() {
this.reconnectSubject.next();
}
public complete() {
this.notificationCountSubject.complete();
this.notificationsSubject.complete();
this.reconnectSubject.complete();
}
onNotificationCountUpdate(message: NotificationCountUpdate) {
if (this.zone) {
this.zone.run(
() => {
this.notificationCountSubject.next(message);
}
);
} else {
this.notificationCountSubject.next(message);
}
}
onNotificationsUpdate(message: NotificationsUpdate) {
this.notificationsSubject.asObservable().pipe(
first()
).subscribe((value) => {
let saveMessage;
if (isDefinedAndNotNull(value) && message.update) {
const findIndex = value.notifications.findIndex(item => item.id.id === message.update.id.id);
if (findIndex !== -1) {
value.notifications.push(message.update);
value.notifications.sort((a, b) => b.createdTime - a.createdTime);
if (value.notifications.length > this.messageLimit) {
value.notifications.pop();
}
}
saveMessage = value;
} else {
saveMessage = message;
}
if (this.zone) {
this.zone.run(
() => {
this.notificationsSubject.next(saveMessage);
this.notificationCountSubject.next(saveMessage);
}
);
} else {
this.notificationsSubject.next(saveMessage);
this.notificationCountSubject.next(saveMessage);
}
});
}
}
export class UnreadCountSubCmd implements WebsocketCmd {
cmdId: number;
}
export class UnreadSubCmd implements WebsocketCmd {
limit: number;
cmdId: number;
constructor(limit = 10) {
this.limit = limit;
}
}
export class UnsubscribeCmd implements WebsocketCmd {
cmdId: number;
}
export class MarkAsReadCmd implements WebsocketCmd {
cmdId: number;
notifications: string[];
constructor(ids: string[]) {
this.notifications = ids;
}
}
export class MarkAllAsReadCmd implements WebsocketCmd {
cmdId: number;
}
export interface NotificationCountUpdateMsg extends CmdUpdateMsg {
cmdUpdateType: CmdUpdateType.NOTIFICATIONS_COUNT;
totalUnreadCount: number;
}
export interface NotificationsUpdateMsg extends CmdUpdateMsg {
cmdUpdateType: CmdUpdateType.NOTIFICATIONS;
totalUnreadCount: number;
update?: Notification;
notifications?: Notification[];
}
export type WebsocketNotificationMsg = NotificationCountUpdateMsg | NotificationsUpdateMsg;
export function isNotificationCountUpdateMsg(message: WebsocketNotificationMsg): message is NotificationCountUpdateMsg {
const updateMsg = (message as CmdUpdateMsg);
return updateMsg.cmdId !== undefined && updateMsg.cmdUpdateType === CmdUpdateType.NOTIFICATIONS_COUNT;
}
export function isNotificationsUpdateMsg(message: WebsocketNotificationMsg): message is NotificationsUpdateMsg {
const updateMsg = (message as CmdUpdateMsg);
return updateMsg.cmdId !== undefined && updateMsg.cmdUpdateType === CmdUpdateType.NOTIFICATIONS;
}
export class NotificationPluginCmdsWrapper {
constructor() {
this.unreadCountSubCmd = null;
this.unreadSubCmd = null;
this.unsubCmd = null;
this.markAsReadCmd = null;
this.markAllAsReadCmd = null;
}
unreadCountSubCmd: UnreadCountSubCmd;
unreadSubCmd: UnreadSubCmd;
unsubCmd: UnsubscribeCmd;
markAsReadCmd: MarkAsReadCmd;
markAllAsReadCmd: MarkAllAsReadCmd;
public hasCommands(): boolean {
return isDefinedAndNotNull(this.unreadCountSubCmd) ||
isDefinedAndNotNull(this.unreadSubCmd) ||
isDefinedAndNotNull(this.unsubCmd) ||
isDefinedAndNotNull(this.markAsReadCmd) ||
isDefinedAndNotNull(this.markAllAsReadCmd);
}
public clear() {
this.unreadCountSubCmd = null;
this.unreadSubCmd = null;
this.unsubCmd = null;
this.markAsReadCmd = null;
this.markAllAsReadCmd = null;
}
public preparePublishCommands(): NotificationPluginCmdsWrapper {
const preparedWrapper = new NotificationPluginCmdsWrapper();
preparedWrapper.unreadCountSubCmd = this.unreadCountSubCmd || undefined;
preparedWrapper.unreadSubCmd = this.unreadSubCmd || undefined;
preparedWrapper.unsubCmd = this.unsubCmd || undefined;
preparedWrapper.markAsReadCmd = this.markAsReadCmd || undefined;
preparedWrapper.markAllAsReadCmd = this.markAllAsReadCmd || undefined;
return preparedWrapper;
}
}

19
ui-ngx/src/app/shared/models/notification.models.ts

@ -35,6 +35,8 @@ export interface Notification {
readonly info: NotificationInfo;
readonly originatorType: NotificationOriginatorType;
readonly status: NotificationStatus;
readonly createdTime: number;
readonly additionalConfig?: PushDeliveryMethodAdditionalConfig;
}
export interface NotificationInfo {
@ -155,8 +157,21 @@ export interface DeliveryMethodNotificationTemplate extends
interface PushDeliveryMethodNotificationTemplate {
subject?: string;
icon?: string;
actionButtonConfig?: string;
additionalConfig: PushDeliveryMethodAdditionalConfig;
}
interface PushDeliveryMethodAdditionalConfig {
icon: {
enabled: boolean;
icon: string;
color: string;
};
actionButtonConfig: {
enabled: boolean;
text: string;
color: string;
link: string;
};
}
interface EmailDeliveryMethodNotificationTemplate {

1
ui-ngx/src/app/shared/models/public-api.ts

@ -39,6 +39,7 @@ export * from './event.models';
export * from './login.models';
export * from './material.models';
export * from './notification.models';
export * from './notification-ws.models';
export * from './oauth2.models';
export * from './queue.models';
export * from './relation.models';

4
ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts

@ -335,7 +335,9 @@ export interface SubscriptionUpdateMsg extends SubscriptionDataHolder {
export enum CmdUpdateType {
ENTITY_DATA = 'ENTITY_DATA',
ALARM_DATA = 'ALARM_DATA',
COUNT_DATA = 'COUNT_DATA'
COUNT_DATA = 'COUNT_DATA',
NOTIFICATIONS_COUNT = 'NOTIFICATIONS_COUNT',
NOTIFICATIONS = 'NOTIFICATIONS'
}
export interface CmdUpdateMsg {

57
ui-ngx/src/app/shared/pipe/date-ago.pipe.ts

@ -0,0 +1,57 @@
///
/// Copyright © 2016-2022 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Inject, Pipe, PipeTransform } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
const intervals = {
years: 31536000,
months: 2592000,
weeks: 604800,
days: 86400,
hr: 3600,
min: 60,
sec: 1
};
@Pipe({
name: 'dateAgo'
})
export class DateAgoPipe implements PipeTransform {
constructor(@Inject(TranslateService) private translate: TranslateService) {
}
transform(value: number): string {
if (value) {
const seconds = Math.floor((+new Date() - +new Date(value)) / 1000);
if (seconds < 29) { // less than 30 seconds ago will show as 'Just now'
return this.translate.instant('timewindow.just-now');
}
let counter;
// tslint:disable-next-line:forin
for (const i in intervals) {
counter = Math.floor(seconds / intervals[i]);
if (counter > 0) {
return this.translate.instant(`timewindow.${i}`, {[i]: counter});
}
}
}
return '';
}
}

1
ui-ngx/src/app/shared/pipe/public-api.ts

@ -14,6 +14,7 @@
/// limitations under the License.
///
export * from './date-ago.pipe';
export * from './enum-to-array.pipe';
export * from './highlight.pipe';
export * from './keyboard-shortcut.pipe';

14
ui-ngx/src/app/shared/shared.module.ts

@ -25,6 +25,7 @@ import { NgxFlowchartModule } from 'ngx-flowchart';
import Flow from '@flowjs/flow.js';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatCardModule } from '@angular/material/card';
@ -170,6 +171,8 @@ import { PhoneInputComponent } from '@shared/components/phone-input.component';
import { CustomDateAdapter } from '@shared/adapter/custom-datatime-adapter';
import { CustomPaginatorIntl } from '@shared/services/custom-paginator-intl';
import { TbScriptLangComponent } from '@shared/components/script-lang.component';
import { NotificationComponent } from '@shared/components/notification/notification.component';
import { DateAgoPipe } from '@shared/pipe/date-ago.pipe';
export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) {
return markedOptionsService;
@ -184,6 +187,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
TruncatePipe,
TbJsonPipe,
FileSizePipe,
DateAgoPipe,
SafePipe,
{
provide: FlowInjectionToken,
@ -285,6 +289,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
TruncatePipe,
TbJsonPipe,
FileSizePipe,
DateAgoPipe,
SafePipe,
SelectableColumnsPipe,
KeyboardShortcutPipe,
@ -300,12 +305,14 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
ProtobufContentComponent,
BranchAutocompleteComponent,
PhoneInputComponent,
TbScriptLangComponent
TbScriptLangComponent,
NotificationComponent
],
imports: [
CommonModule,
RouterModule,
TranslateModule,
MatBadgeModule,
MatButtonModule,
MatButtonToggleModule,
MatCheckboxModule,
@ -418,6 +425,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
FabToolbarComponent,
WidgetsBundleSelectComponent,
ValueInputComponent,
MatBadgeModule,
MatButtonModule,
MatButtonToggleModule,
MatCheckboxModule,
@ -491,6 +499,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
TbJsonPipe,
KeyboardShortcutPipe,
FileSizePipe,
DateAgoPipe,
SafePipe,
SelectableColumnsPipe,
RouterModule,
@ -506,7 +515,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
ProtobufContentComponent,
BranchAutocompleteComponent,
PhoneInputComponent,
TbScriptLangComponent
TbScriptLangComponent,
NotificationComponent
]
})
export class SharedModule { }

18
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -2669,6 +2669,7 @@
"copied": "Copied!"
},
"notification": {
"action-button": "Action button",
"add-notification-target": "Add notification target",
"add-notification-template": "Add notification template",
"add-target": "Add target",
@ -2676,6 +2677,8 @@
"at-least-one-should-be-selected": "At least one should be selected",
"all": "All",
"basic-settings": "Basic settings",
"button-text": "Button text",
"button-text-required": "Button text is required",
"compose": "Compose",
"copy-notification-template": "Copy notification template",
"create-target": "Create target",
@ -2700,8 +2703,10 @@
"email-settings": "Email settings",
"get-customer-id-from-originator": "Get Customer id from originator",
"inbox": "Inbox",
"link": "Link",
"link-required": "Link is required",
"management": "Notification management",
"mark-all-read": "Mark all read",
"mark-all-as-read": "Mark all as read",
"mark-as-read": "Mark as read",
"message": "Message",
"message-required": "Message is required",
@ -2710,6 +2715,7 @@
"no-notification-request": "No notification request",
"no-notification-templates": "No notification templates",
"no-targets-notification": "No targets notification",
"notification": "Notification",
"notification-center": "Notification center",
"notification-target": "Notification target",
"recipients-count": "{ count, plural, 1 {1 Recipient} other {# Recipients} }",
@ -2753,6 +2759,7 @@
"text": "Text",
"type": "Type",
"unread": "Unread",
"view-all": "View al;",
"web-settings": "Web settings"
},
"ota-update": {
@ -3465,10 +3472,16 @@
"days": "Days"
},
"timewindow": {
"years": "{ years, plural, 1 { year } other {# years } }",
"months": "{ months, plural, 1 { month } other {# months } }",
"weeks": "{ weeks, plural, 1 { week } other {# weeks } }",
"days": "{ days, plural, 1 { day } other {# days } }",
"hours": "{ hours, plural, 0 { hour } 1 {1 hour } other {# hours } }",
"hr": "{{ hr }} hr",
"minutes": "{ minutes, plural, 0 { minute } 1 {1 minute } other {# minutes } }",
"min": "{{ min }} min",
"seconds": "{ seconds, plural, 0 { second } 1 {1 second } other {# seconds } }",
"sec": "{{ sec }} sec",
"realtime": "Realtime",
"history": "History",
"last-prefix": "last",
@ -3478,7 +3491,8 @@
"last": "Last",
"time-period": "Time period",
"hide": "Hide",
"interval": "Interval"
"interval": "Interval",
"just-now": "Just now"
},
"user": {
"user": "User",

Loading…
Cancel
Save