Browse Source

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

pull/7980/head
ViacheslavKlimov 4 years ago
parent
commit
366b0098a4
  1. 5
      ui-ngx/angular.json
  2. 2
      ui-ngx/package.json
  3. 8
      ui-ngx/src/app/core/http/entity.service.ts
  4. 19
      ui-ngx/src/app/core/http/notification.service.ts
  5. 4
      ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts
  6. 1
      ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts
  7. 27
      ui-ngx/src/app/modules/home/pages/notification-center/inbox-table/inbox-table-header.component.html
  8. 71
      ui-ngx/src/app/modules/home/pages/notification-center/inbox-table/inbox-table-header.component.scss
  9. 39
      ui-ngx/src/app/modules/home/pages/notification-center/inbox-table/inbox-table-header.component.ts
  10. 3
      ui-ngx/src/app/modules/home/pages/notification-center/notification-center.component.html
  11. 4
      ui-ngx/src/app/modules/home/pages/notification-center/notification-center.component.scss
  12. 5
      ui-ngx/src/app/modules/home/pages/notification-center/notification-center.component.ts
  13. 16
      ui-ngx/src/app/modules/home/pages/notification-center/notification-center.module.ts
  14. 82
      ui-ngx/src/app/modules/home/pages/notification-center/notification-table/inbox-table-config.ts
  15. 7
      ui-ngx/src/app/modules/home/pages/notification-center/notification-table/notification-table.component.ts
  16. 148
      ui-ngx/src/app/modules/home/pages/notification-center/notification-table/request-table-config.ts
  17. 7
      ui-ngx/src/app/modules/home/pages/notification-center/notification-table/template-table-config.ts
  18. 83
      ui-ngx/src/app/modules/home/pages/notification-center/request-table/request-notification-dialog.component.html
  19. 100
      ui-ngx/src/app/modules/home/pages/notification-center/request-table/request-notification-dialog.component.scss
  20. 155
      ui-ngx/src/app/modules/home/pages/notification-center/request-table/request-notification-dialog.componet.ts
  21. 3
      ui-ngx/src/app/modules/home/pages/notification-center/targets-table/target-notification-dialog.component.html
  22. 74
      ui-ngx/src/app/modules/home/pages/notification-center/template-dialog/template-notification-dialog.component.html
  23. 95
      ui-ngx/src/app/modules/home/pages/notification-center/template-dialog/template-notification-dialog.component.ts
  24. 57
      ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-autocomplete.component.html
  25. 36
      ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-autocomplete.component.scss
  26. 213
      ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-autocomplete.component.ts
  27. 159
      ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-notification-dialog.component.html
  28. 101
      ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-notification-dialog.component.scss
  29. 255
      ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-notification-dialog.component.ts
  30. 0
      ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-table-header.component.html
  31. 0
      ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-table-header.component.scss
  32. 0
      ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-table-header.component.ts
  33. 9
      ui-ngx/src/app/shared/components/entity/entity-list.component.html
  34. 9
      ui-ngx/src/app/shared/components/entity/entity-list.component.ts
  35. 2
      ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.html
  36. 2
      ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.scss
  37. 65
      ui-ngx/src/app/shared/models/notification.models.ts
  38. 6
      ui-ngx/src/app/shared/shared.module.ts
  39. 52
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  40. 12
      ui-ngx/yarn.lock

5
ui-ngx/angular.json

@ -49,6 +49,11 @@
"input": "./node_modules/ace-builds/src-noconflict/", "input": "./node_modules/ace-builds/src-noconflict/",
"output": "/" "output": "/"
}, },
{
"glob": "**/*",
"input": "node_modules/tinymce",
"output": "/assets/tinymce/"
},
{ {
"glob": "marker-icon-2x.png", "glob": "marker-icon-2x.png",
"input": "node_modules/leaflet/dist/images/", "input": "node_modules/leaflet/dist/images/",

2
ui-ngx/package.json

@ -40,6 +40,7 @@
"@ngrx/store-devtools": "^12.5.1", "@ngrx/store-devtools": "^12.5.1",
"@ngx-translate/core": "^13.0.0", "@ngx-translate/core": "^13.0.0",
"@ngx-translate/http-loader": "^6.0.0", "@ngx-translate/http-loader": "^6.0.0",
"@tinymce/tinymce-angular": "~4.2.4",
"ace-builds": "1.4.13", "ace-builds": "1.4.13",
"ace-diff": "^3.0.3", "ace-diff": "^3.0.3",
"angular-gridster2": "~12.1.1", "angular-gridster2": "~12.1.1",
@ -93,6 +94,7 @@
"split.js": "^1.6.4", "split.js": "^1.6.4",
"systemjs": "6.11.0", "systemjs": "6.11.0",
"tinycolor2": "^1.4.2", "tinycolor2": "^1.4.2",
"tinymce": "~5.10.7",
"tooltipster": "^4.2.8", "tooltipster": "^4.2.8",
"ts-transformer-keys": "^0.4.3", "ts-transformer-keys": "^0.4.3",
"tslib": "^2.3.1", "tslib": "^2.3.1",

8
ui-ngx/src/app/core/http/entity.service.ts

@ -88,6 +88,7 @@ import { WidgetService } from '@core/http/widget.service';
import { DeviceProfileService } from '@core/http/device-profile.service'; import { DeviceProfileService } from '@core/http/device-profile.service';
import { QueueService } from '@core/http/queue.service'; import { QueueService } from '@core/http/queue.service';
import { AssetProfileService } from '@core/http/asset-profile.service'; import { AssetProfileService } from '@core/http/asset-profile.service';
import { NotificationService } from '@core/http/notification.service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -113,7 +114,8 @@ export class EntityService {
private deviceProfileService: DeviceProfileService, private deviceProfileService: DeviceProfileService,
private assetProfileService: AssetProfileService, private assetProfileService: AssetProfileService,
private utils: UtilsService, private utils: UtilsService,
private queueService: QueueService private queueService: QueueService,
private notificationService: NotificationService
) { } ) { }
private getEntityObservable(entityType: EntityType, entityId: string, private getEntityObservable(entityType: EntityType, entityId: string,
@ -402,6 +404,10 @@ export class EntityService {
pageLink.sortOrder.property = 'title'; pageLink.sortOrder.property = 'title';
entitiesObservable = this.widgetService.getWidgetBundles(pageLink, config); entitiesObservable = this.widgetService.getWidgetBundles(pageLink, config);
break; break;
case EntityType.NOTIFICATION_TARGET:
pageLink.sortOrder.property = 'name';
entitiesObservable = this.notificationService.getNotificationTargets(pageLink, config);
break;
} }
return entitiesObservable; return entitiesObservable;
} }

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

@ -23,6 +23,8 @@ import { PageData } from '@shared/models/page/page-data';
import { import {
Notification, Notification,
NotificationRequest, NotificationRequest,
NotificationRequestInfo,
NotificationRequestPreview,
NotificationRule, NotificationRule,
NotificationSettings, NotificationSettings,
NotificationTarget, NotificationTarget,
@ -42,15 +44,16 @@ export class NotificationService {
) { ) {
} }
public getNotifications(pageLink: PageLink, config?: RequestConfig): Observable<PageData<Notification>> { public getNotifications(pageLink: PageLink, unreadOnly = false, config?: RequestConfig): Observable<PageData<Notification>> {
return this.http.get<PageData<Notification>>(`/api/notifications${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)); return this.http.get<PageData<Notification>>(`/api/notifications${pageLink.toQuery()}&unreadOnly=${unreadOnly}`,
defaultHttpOptionsFromConfig(config));
} }
public markNotificationAsRead(id: string, config?: RequestConfig): Observable<void> { public markNotificationAsRead(id: string, config?: RequestConfig): Observable<void> {
return this.http.put<void>(`/api/notification/${id}/read`, defaultHttpOptionsFromConfig(config)); return this.http.put<void>(`/api/notification/${id}/read`, defaultHttpOptionsFromConfig(config));
} }
public createNotificationRequest(notification: NotificationRequest, config?: RequestConfig): Observable<any> { public createNotificationRequest(notification: NotificationRequest, config?: RequestConfig): Observable<NotificationRequest> {
return this.http.post<NotificationRequest>('/api/notification/request', notification, defaultHttpOptionsFromConfig(config)); return this.http.post<NotificationRequest>('/api/notification/request', notification, defaultHttpOptionsFromConfig(config));
} }
@ -62,8 +65,14 @@ export class NotificationService {
return this.http.delete<void>(`/api/notification/request/${id}`, defaultHttpOptionsFromConfig(config)); return this.http.delete<void>(`/api/notification/request/${id}`, defaultHttpOptionsFromConfig(config));
} }
public getNotificationRequests(pageLink: PageLink, config?: RequestConfig): Observable<PageData<NotificationRequest>> { public getNotificationRequestPreview(notification: NotificationRequest, config?: RequestConfig): Observable<NotificationRequestPreview> {
return this.http.get<PageData<NotificationRequest>>('/api/notification/request/', defaultHttpOptionsFromConfig(config)); return this.http.post<NotificationRequestPreview>('/api/notification/request/preview',
notification, defaultHttpOptionsFromConfig(config));
}
public getNotificationRequests(pageLink: PageLink, config?: RequestConfig): Observable<PageData<NotificationRequestInfo>> {
return this.http.get<PageData<NotificationRequestInfo>>(`/api/notification/requests${pageLink.toQuery()}`,
defaultHttpOptionsFromConfig(config));
} }
public getNotificationSettings(config?: RequestConfig): Observable<NotificationSettings> { public getNotificationSettings(config?: RequestConfig): Observable<NotificationSettings> {

4
ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts

@ -692,4 +692,8 @@ export class EntitiesTableComponent extends PageComponent implements IEntitiesTa
this.updateData(); this.updateData();
} }
} }
detectChanges() {
this.cd.markForCheck();
}
} }

1
ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts

@ -85,4 +85,5 @@ export interface IEntitiesTableComponent {
cellStyle(entity: BaseData<HasId>, column: EntityColumn<BaseData<HasId>>, row: number): any; cellStyle(entity: BaseData<HasId>, column: EntityColumn<BaseData<HasId>>, row: number): any;
trackByColumnKey(index, column: EntityTableColumn<BaseData<HasId>>): string; trackByColumnKey(index, column: EntityTableColumn<BaseData<HasId>>): string;
trackByEntityId(index: number, entity: BaseData<HasId>): string; trackByEntityId(index: number, entity: BaseData<HasId>): string;
detectChanges(): void;
} }

27
ui-ngx/src/app/modules/home/pages/notification-center/inbox-table/inbox-table-header.component.html

@ -0,0 +1,27 @@
<!--
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.
-->
<div fxFlex="100" fxLayout="row" fxLayoutAlign="start center">
<mat-button-toggle-group class="tb-notification-unread-toggle-group"
style="width: 180px;"
[ngModel]="entitiesTableConfig.componentsData.unreadOnly"
(ngModelChange)="changeUnreadOnly($event)">
<mat-button-toggle fxFlex [value]=true>Unread</mat-button-toggle>
<mat-button-toggle fxFlex [value]=false>{{ 'notification.all' | translate }}</mat-button-toggle>
</mat-button-toggle-group>
</div>

71
ui-ngx/src/app/modules/home/pages/notification-center/inbox-table/inbox-table-header.component.scss

@ -0,0 +1,71 @@
/**
* 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 "../../../../../../theme";
:host ::ng-deep {
.mat-button-toggle-group.tb-notification-unread-toggle-group {
&.mat-button-toggle-group-appearance-standard {
border: none;
border-radius: 14px;
.mat-button-toggle + .mat-button-toggle {
border-left: none;
}
}
.mat-button-toggle {
background: rgba(0, 0, 0, 0.06);
height: 28px;
align-items: center;
display: flex;
.mat-button-toggle-ripple {
top: 2px;
left: 2px;
right: 2px;
bottom: 2px;
border-radius: 12px;
}
}
.mat-button-toggle-button {
color: #959595;
}
.mat-button-toggle-focus-overlay {
border-radius: 14px;
margin: 2px;
}
.mat-button-toggle-checked .mat-button-toggle-button {
background-color: $tb-primary-color;
color: #fff;
border-radius: 14px;
margin-left: 2px;
margin-right: 2px;
}
.mat-button-toggle-appearance-standard .mat-button-toggle-label-content {
line-height: 24px;
font-size: 16px;
font-weight: 500;
}
.mat-button-toggle-checked.mat-button-toggle-appearance-standard:not(.mat-button-toggle-disabled):hover .mat-button-toggle-focus-overlay {
opacity: .01;
}
}
}

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

@ -0,0 +1,39 @@
///
/// 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 } from '@angular/core';
import { EntityTableHeaderComponent } from '@home/components/entity/entity-table-header.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Notification } from '@shared/models/notification.models';
@Component({
selector: 'tb-inbox-table-header',
templateUrl: './inbox-table-header.component.html',
styleUrls: ['inbox-table-header.component.scss']
})
export class InboxTableHeaderComponent extends EntityTableHeaderComponent<Notification> {
constructor(protected store: Store<AppState>) {
super(store);
}
changeUnreadOnly(unreadOnly: boolean) {
this.entitiesTableConfig.componentsData.unreadOnly = unreadOnly;
this.entitiesTableConfig.getTable().resetSortAndFilter(true);
}
}

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

@ -41,6 +41,7 @@
</mat-tab> </mat-tab>
<mat-tab label="{{ 'notification.sent' | translate }}"> <mat-tab label="{{ 'notification.sent' | translate }}">
<tb-notification-table <tb-notification-table
#notificationRequest
[notificationType]="entityType.NOTIFICATION_REQUEST"> [notificationType]="entityType.NOTIFICATION_REQUEST">
</tb-notification-table> </tb-notification-table>
</mat-tab> </mat-tab>
@ -62,7 +63,7 @@
<mat-tab disabled> <mat-tab disabled>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<div fxFlex="100" fxLayout="row" fxLayoutAlign="end center"> <div fxFlex="100" fxLayout="row" fxLayoutAlign="end center">
<button mat-flat-button color="primary"> <button mat-flat-button color="primary" (click)="sendNotification($event)">
<div fxLayout="row" fxLayoutAlign="start center"> <div fxLayout="row" fxLayoutAlign="start center">
<mat-icon>send</mat-icon>{{ 'notification.send-notification' | translate }} <mat-icon>send</mat-icon>{{ 'notification.send-notification' | translate }}
</div> </div>

4
ui-ngx/src/app/modules/home/pages/notification-center/notification-center.component.scss

@ -149,6 +149,10 @@
height: 56px; height: 56px;
} }
.mat-toolbar-single-row {
padding: 0 24px 0 12px;
}
.title-container { .title-container {
width: 100%; width: 100%;
} }

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

@ -34,6 +34,7 @@ export class NotificationCenterComponent extends PageComponent {
entityType = EntityType; entityType = EntityType;
@ViewChild('matTabGroup', {static: true}) matTabs: MatTabGroup; @ViewChild('matTabGroup', {static: true}) matTabs: MatTabGroup;
@ViewChild('notificationRequest', {static: true}) notificationRequestTable: NotificationTableComponent;
@ViewChildren(NotificationTableComponent) tableComponent: QueryList<NotificationTableComponent>; @ViewChildren(NotificationTableComponent) tableComponent: QueryList<NotificationTableComponent>;
@ -53,4 +54,8 @@ export class NotificationCenterComponent extends PageComponent {
private get currentTableComponent(): NotificationTableComponent { private get currentTableComponent(): NotificationTableComponent {
return this.tableComponent.get(this.matTabs.selectedIndex); return this.tableComponent.get(this.matTabs.selectedIndex);
} }
sendNotification($event: Event) {
this.notificationRequestTable.entityTableConfig.onEntityAction({event: $event, action: 'add', entity: null});
}
} }

16
ui-ngx/src/app/modules/home/pages/notification-center/notification-center.module.ts

@ -28,22 +28,32 @@ import {
} from '@home/pages/notification-center/notification-table/notification-table.component'; } from '@home/pages/notification-center/notification-table/notification-table.component';
import { import {
TemplateNotificationDialogComponent TemplateNotificationDialogComponent
} from '@home/pages/notification-center/template-dialog/template-notification-dialog.component'; } from '@home/pages/notification-center/template-table/template-notification-dialog.component';
import { import {
TargetTableHeaderComponent TargetTableHeaderComponent
} from '@home/pages/notification-center/targets-table/target-table-header.component'; } from '@home/pages/notification-center/targets-table/target-table-header.component';
import { import {
TemplateTableHeaderComponent TemplateTableHeaderComponent
} from '@home/pages/notification-center/template-dialog/template-table-header.component'; } from '@home/pages/notification-center/template-table/template-table-header.component';
import {
RequestNotificationDialogComponent
} from '@home/pages/notification-center/request-table/request-notification-dialog.componet';
import {
TemplateAutocompleteComponent
} from '@home/pages/notification-center/template-table/template-autocomplete.component';
import { InboxTableHeaderComponent } from '@home/pages/notification-center/inbox-table/inbox-table-header.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
NotificationCenterComponent, NotificationCenterComponent,
TargetNotificationDialogComponent, TargetNotificationDialogComponent,
TemplateNotificationDialogComponent, TemplateNotificationDialogComponent,
RequestNotificationDialogComponent,
TargetTableHeaderComponent, TargetTableHeaderComponent,
TemplateTableHeaderComponent, TemplateTableHeaderComponent,
NotificationTableComponent NotificationTableComponent,
TemplateAutocompleteComponent,
InboxTableHeaderComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,

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

@ -15,6 +15,7 @@
/// ///
import { import {
CellActionDescriptor,
DateEntityTableColumn, DateEntityTableColumn,
EntityTableColumn, EntityTableColumn,
EntityTableConfig EntityTableConfig
@ -22,33 +23,96 @@ import {
import { EntityTypeResource } from '@shared/models/entity-type.models'; import { EntityTypeResource } from '@shared/models/entity-type.models';
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { Direction } from '@shared/models/page/sort-order'; import { Direction } from '@shared/models/page/sort-order';
import { Notification } from '@shared/models/notification.models'; import {
Notification,
NotificationStatus,
NotificationTemplateTypeTranslateMap
} from '@shared/models/notification.models';
import { NotificationService } from '@core/http/notification.service'; import { NotificationService } from '@core/http/notification.service';
import { InboxTableHeaderComponent } from '@home/pages/notification-center/inbox-table/inbox-table-header.component';
import { TranslateService } from '@ngx-translate/core';
import { take } from 'rxjs/operators';
export class InboxTableConfig extends EntityTableConfig<Notification> { export class InboxTableConfig extends EntityTableConfig<Notification> {
constructor(private notificationService: NotificationService, constructor(private notificationService: NotificationService,
private datePipe: DatePipe, private translate: TranslateService,
updateOnInit = true) { private datePipe: DatePipe) {
super(); super();
this.loadDataOnInit = true;
this.entitiesDeleteEnabled = false; this.entitiesDeleteEnabled = false;
this.entityTranslations = { this.entityTranslations = {
noEntities: 'notification.no-inbox-notification' noEntities: 'notification.no-inbox-notification',
search: 'notification.search-notification'
}; };
this.entityResources = {} as EntityTypeResource<Notification>; this.entityResources = {} as EntityTypeResource<Notification>;
this.entitiesFetchFunction = pageLink => this.notificationService.getNotifications(pageLink); this.entitiesFetchFunction = pageLink => this.notificationService.getNotifications(pageLink, this.componentsData.unreadOnly);
this.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC}; this.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC};
this.componentsData = {
unreadOnly: true
};
this.cellActionDescriptors = this.configureCellActions();
this.headerComponent = InboxTableHeaderComponent;
this.headerActionDescriptors = [{
name: this.translate.instant('notification.mark-all-read'),
icon: 'done_all',
isEnabled: () => true,
onAction: $event => this.markAllRead($event)
}];
this.columns.push( this.columns.push(
new DateEntityTableColumn<Notification>('createdTime', 'notification.created-time', this.datePipe, '150px'), new DateEntityTableColumn<Notification>('createdTime', 'notification.created-time', this.datePipe, '150px'),
new EntityTableColumn<Notification>('type', 'notification.type', '10%'), new EntityTableColumn<Notification>('type', 'notification.type', '10%', (notification) =>
new EntityTableColumn<Notification>('text', 'notification.text', '40%'), this.translate.instant(NotificationTemplateTypeTranslateMap.get(notification.type).name)),
new EntityTableColumn<Notification>('info.description', 'notification.description', '50%') new EntityTableColumn<Notification>('subject', 'notification.subject', '30%'),
new EntityTableColumn<Notification>('text', 'notification.text', '60%')
); );
} }
private configureCellActions(): Array<CellActionDescriptor<Notification>> {
return [{
name: this.translate.instant('notification.mark-as-read'),
icon: 'check_circle_outline',
isEnabled: (notification) => notification.status !== NotificationStatus.READ,
onAction: ($event, entity) => this.markAsRead($event, entity)
}];
}
private markAllRead($event: Event) {
if ($event) {
$event.stopPropagation();
}
if (this.componentsData.unreadOnly) {
this.getTable().resetSortAndFilter(true);
} else {
this.updateData();
}
}
private markAsRead($event, entity){
if ($event) {
$event.stopPropagation();
}
this.notificationService.markNotificationAsRead(entity.id.id).subscribe(() => {
if (this.componentsData.unreadOnly) {
this.getTable().dataSource.pageData$.pipe(take(1)).subscribe(
(value) => {
if (value.data.length === 1 && this.getTable().pageLink.page) {
this.getTable().pageLink.page--;
}
this.updateData();
}
);
} else {
entity.status = NotificationStatus.READ;
this.getTable().detectChanges();
}
});
}
} }

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

@ -25,6 +25,7 @@ import { EntityTableConfig } from '@home/models/entity/entities-table-config.mod
import { InboxTableConfig } from '@home/pages/notification-center/notification-table/inbox-table-config'; import { InboxTableConfig } from '@home/pages/notification-center/notification-table/inbox-table-config';
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { TemplateTableConfig } from '@home/pages/notification-center/notification-table/template-table-config'; import { TemplateTableConfig } from '@home/pages/notification-center/notification-table/template-table-config';
import { RequestTableConfig } from '@home/pages/notification-center/notification-table/request-table-config';
@Component({ @Component({
selector: 'tb-notification-table', selector: 'tb-notification-table',
@ -68,6 +69,7 @@ export class NotificationTableComponent implements OnInit {
case EntityType.NOTIFICATION: case EntityType.NOTIFICATION:
return new InboxTableConfig( return new InboxTableConfig(
this.notificationService, this.notificationService,
this.translate,
this.datePipe this.datePipe
); );
case EntityType.NOTIFICATION_TEMPLATE: case EntityType.NOTIFICATION_TEMPLATE:
@ -77,10 +79,11 @@ export class NotificationTableComponent implements OnInit {
this.dialog this.dialog
); );
case EntityType.NOTIFICATION_REQUEST: case EntityType.NOTIFICATION_REQUEST:
return new TemplateTableConfig( return new RequestTableConfig(
this.notificationService, this.notificationService,
this.translate, this.translate,
this.dialog this.dialog,
this.datePipe
); );
} }
} }

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

@ -0,0 +1,148 @@
///
/// 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 {
CellActionDescriptor,
DateEntityTableColumn,
EntityTableColumn,
EntityTableConfig
} from '@home/models/entity/entities-table-config.models';
import {
NotificationDeliveryMethodTranslateMap,
NotificationRequest,
NotificationRequestInfo,
NotificationRequestStatus,
NotificationRequestStatusTranslateMap,
NotificationTemplate
} from '@shared/models/notification.models';
import { NotificationService } from '@core/http/notification.service';
import { TranslateService } from '@ngx-translate/core';
import { MatDialog } from '@angular/material/dialog';
import { EntityTypeResource } from '@shared/models/entity-type.models';
import { Direction } from '@shared/models/page/sort-order';
import { DatePipe } from '@angular/common';
import { EntityAction } from '@home/models/entity/entity-component.models';
import {
RequestNotificationDialogComponent,
RequestNotificationDialogData
} from '@home/pages/notification-center/request-table/request-notification-dialog.componet';
import { PageLink } from '@shared/models/page/page-link';
export class RequestTableConfig extends EntityTableConfig<NotificationRequest, PageLink, NotificationRequestInfo> {
constructor(private notificationService: NotificationService,
private translate: TranslateService,
private dialog: MatDialog,
private datePipe: DatePipe) {
super();
this.loadDataOnInit = false;
this.searchEnabled = false;
this.entityTranslations = {
noEntities: 'notification.no-notification-request',
};
this.entityResources = {} as EntityTypeResource<NotificationRequest>;
this.entitiesFetchFunction = pageLink => this.notificationService.getNotificationRequests(pageLink);
this.deleteEnabled = (request) => request.status === NotificationRequestStatus.SCHEDULED;
this.deleteEntityTitle = () => this.translate.instant('notification.delete-request-title');
this.deleteEntityContent = () => this.translate.instant('notification.delete-request-text');
this.deleteEntity = id => this.notificationService.deleteNotificationRequest(id.id);
this.cellActionDescriptors = this.configureCellActions();
this.onEntityAction = action => this.onRequestAction(action);
this.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC};
this.columns.push(
new DateEntityTableColumn<NotificationRequestInfo>('createdTime', 'notification.created-time', this.datePipe, '150px'),
new EntityTableColumn<NotificationRequestInfo>('status', 'notification.status', '15%',
request => this.requestStatus(request.status), request => this.requestStatusStyle(request.status)),
new EntityTableColumn<NotificationRequest>('deliveryMethods', 'notification.delivery-method', '35%',
(request) => request.deliveryMethods
.map((deliveryMethod) => this.translate.instant(NotificationDeliveryMethodTranslateMap.get(deliveryMethod))).join(', '),
() => ({}), false),
new EntityTableColumn<NotificationRequest>('templateName', 'notification.template', '50%')
);
}
private configureCellActions(): Array<CellActionDescriptor<NotificationRequestInfo>> {
return [];
}
private createRequest($event: Event, request: NotificationRequest, isAdd = false) {
if ($event) {
$event.stopPropagation();
}
this.dialog.open<RequestNotificationDialogComponent, RequestNotificationDialogData,
NotificationTemplate>(RequestNotificationDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
isAdd,
request
}
}).afterClosed()
.subscribe((res) => {
if (res) {
this.updateData();
}
});
}
private onRequestAction(action: EntityAction<NotificationRequest>): boolean {
switch (action.action) {
case 'add':
this.createRequest(action.event, action.entity, true);
return true;
}
return false;
}
private requestStatus(status: NotificationRequestStatus): string {
const translateKey = NotificationRequestStatusTranslateMap.get(status);
let backgroundColor = 'rgba(25, 128, 56, 0.08)';
switch (status) {
case NotificationRequestStatus.SCHEDULED:
backgroundColor = 'rgba(48, 86, 128, 0.08)';
break;
case NotificationRequestStatus.PROCESSING:
backgroundColor = 'rgba(212, 125, 24, 0.08)';
break;
}
return `<div style="border-radius: 16px; height: 32px; line-height: 32px; padding: 0 12px; width: fit-content; background-color: ${backgroundColor}">
${this.translate.instant(translateKey)}
</div>`;
}
private requestStatusStyle(status: NotificationRequestStatus): object {
const styleObj = {
fontSize: '14px',
color: '#198038',
cursor: 'pointer'
};
switch (status) {
case NotificationRequestStatus.SCHEDULED:
styleObj.color = '#305680';
break;
case NotificationRequestStatus.PROCESSING:
styleObj.color = '#D47D18';
break;
}
return styleObj;
}
}

7
ui-ngx/src/app/modules/home/pages/notification-center/notification-table/template-table-config.ts

@ -28,11 +28,11 @@ import { MatDialog } from '@angular/material/dialog';
import { import {
TemplateNotificationDialogComponent, TemplateNotificationDialogComponent,
TemplateNotificationDialogData TemplateNotificationDialogData
} from '@home/pages/notification-center/template-dialog/template-notification-dialog.component'; } from '@home/pages/notification-center/template-table/template-notification-dialog.component';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { import {
TemplateTableHeaderComponent TemplateTableHeaderComponent
} from '@home/pages/notification-center/template-dialog/template-table-header.component'; } from '@home/pages/notification-center/template-table/template-table-header.component';
export class TemplateTableConfig extends EntityTableConfig<NotificationTemplate> { export class TemplateTableConfig extends EntityTableConfig<NotificationTemplate> {
@ -63,7 +63,8 @@ export class TemplateTableConfig extends EntityTableConfig<NotificationTemplate>
this.columns.push( this.columns.push(
new EntityTableColumn<NotificationTemplate>('notificationType', 'notification.type', '15%'), new EntityTableColumn<NotificationTemplate>('notificationType', 'notification.type', '15%'),
new EntityTableColumn<NotificationTemplate>('name', 'notification.template', '25%'), new EntityTableColumn<NotificationTemplate>('name', 'notification.template', '25%'),
new EntityTableColumn<NotificationTemplate>('subject', 'notification.subject', '25%'), new EntityTableColumn<NotificationTemplate>('configuration.notificationSubject', 'notification.subject', '25%',
(template) => template.configuration.notificationSubject, () => ({}), false),
new EntityTableColumn<NotificationTemplate>('configuration.defaultTextTemplate', 'notification.message', '35%', new EntityTableColumn<NotificationTemplate>('configuration.defaultTextTemplate', 'notification.message', '35%',
(template) => template.configuration.defaultTextTemplate, () => ({}), false) (template) => template.configuration.defaultTextTemplate, () => ({}), false)
); );

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

@ -0,0 +1,83 @@
<!--
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.
-->
<mat-toolbar color="primary">
<h2 translate>notification.new-notification</h2>
<span fxFlex></span>
<button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<div mat-dialog-content>
<mat-horizontal-stepper [linear]="true" labelPosition="end" #createNotification
[orientation]="(stepperOrientation | async)"
(selectionChange)="changeStep($event)">
<ng-template matStepperIcon="edit">
<mat-icon>check</mat-icon>
</ng-template>
<mat-step [stepControl]="notificationRequestForm">
<ng-template matStepLabel>{{ 'notification.compose' | translate }}</ng-template>
<form [formGroup]="notificationRequestForm" style="padding-bottom: 16px;">
<tb-template-autocomplete
required
formControlName="templateId">
</tb-template-autocomplete>
<tb-entity-list
required
formControlName="targets"
labelText="notification.targets"
placeholderText="notification.target"
requiredText="notification.targets-required"
[entityType]="entityType.NOTIFICATION_TARGET">
</tb-entity-list>
</form>
</mat-step>
<mat-step>
<ng-template matStepLabel>{{ 'notification.review' | translate }}</ng-template>
<mat-progress-spinner color="warn" mode="indeterminate"
strokeWidth="5" *ngIf="(isLoading$ | async) && !preview">
</mat-progress-spinner>
<div *ngIf="preview">
<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>
<div class="details-recipients" *ngIf="notificationRequestForm.get('targets').value.length">
<div *ngFor="let detail of preview.recipientsCountByTarget | keyvalue" class="details-recipient">
<span class="number">{{ detail.value }}</span>{{ detail.key }}
</div>
</div>
</section>
</div>
</mat-step>
</mat-horizontal-stepper>
</div>
<mat-divider></mat-divider>
<div mat-dialog-actions fxLayout="row">
<button mat-stroked-button *ngIf="selectedIndex > 0"
(click)="backStep()">{{ 'action.back' | translate }}</button>
<span fxFlex></span>
<button mat-raised-button
color="primary"
(click)="nextStep()">{{ nextStepLabel() | translate }}</button>
</div>

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

@ -0,0 +1,100 @@
/**
* 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 "../../../../../../scss/constants";
:host-context(.tb-fullscreen-dialog .mat-dialog-container) {
width: 640px;
.preview-group {
padding: 16px;
margin-bottom: 10px;
border: 1px groove rgba(0, 0, 0, .12);
border-radius: 4px;
.mat-icon {
color: rgba(0, 0, 0, 0.38);
}
.total-recipients {
font-weight: 500;
letter-spacing: .25px;
}
&> div:not(:last-child) {
margin-bottom: 12px;
}
.details-recipient {
font-size: 14px;
line-height: 14px;
letter-spacing: .2px;
.number {
font-weight: 500;
margin-right: 4px;
}
&:not(:last-of-type) {
margin-bottom: 8px;
}
}
}
@media #{$mat-gt-xs} {
.mat-dialog-content {
max-height: 75vh;
}
}
}
:host ::ng-deep {
.mat-dialog-content {
display: flex;
flex-direction: column;
height: 100%;
padding: 0 !important;
.mat-stepper-horizontal {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
@media #{$mat-lt-sm} {
.mat-step-label {
white-space: normal;
overflow: visible;
.mat-step-text-label {
overflow: visible;
}
}
}
.mat-horizontal-content-container {
height: 400px;
max-height: 100%;
width: 100%;;
overflow-y: auto;
scrollbar-gutter: stable;
@media #{$mat-gt-sm} {
min-width: 500px;
}
}
.mat-horizontal-stepper-content[aria-expanded=true] {
height: 100%;
position: relative;
}
}
}
}

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

@ -0,0 +1,155 @@
///
/// 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 { NotificationRequest, NotificationRequestPreview } from '@shared/models/notification.models';
import { Component, Inject, OnDestroy, ViewChild } from '@angular/core';
import { DialogComponent } from '@shared/components/dialog.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Router } from '@angular/router';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { NotificationService } from '@core/http/notification.service';
import { deepTrim, isDefined } from '@core/utils';
import { Observable, Subject } from 'rxjs';
import { EntityType } from '@shared/models/entity-type.models';
import { BreakpointObserver } from '@angular/cdk/layout';
import { MatStepper } from '@angular/material/stepper';
import { StepperOrientation, StepperSelectionEvent } from '@angular/cdk/stepper';
import { MediaBreakpoints } from '@shared/models/constants';
import { map } from 'rxjs/operators';
export interface RequestNotificationDialogData {
request?: NotificationRequest;
isAdd?: boolean;
}
@Component({
selector: 'tb-request-notification-dialog',
templateUrl: './request-notification-dialog.component.html',
styleUrls: ['./request-notification-dialog.component.scss']
})
export class RequestNotificationDialogComponent extends
DialogComponent<RequestNotificationDialogComponent, NotificationRequest> implements OnDestroy {
@ViewChild('createNotification', {static: true}) createNotification: MatStepper;
stepperOrientation: Observable<StepperOrientation>;
isAdd = true;
entityType = EntityType;
notificationRequestForm: FormGroup;
selectedIndex = 0;
preview: NotificationRequestPreview = null;
private readonly destroy$ = new Subject<void>();
constructor(protected store: Store<AppState>,
protected router: Router,
protected dialogRef: MatDialogRef<RequestNotificationDialogComponent, NotificationRequest>,
@Inject(MAT_DIALOG_DATA) public data: RequestNotificationDialogData,
private breakpointObserver: BreakpointObserver,
private fb: FormBuilder,
private notificationService: NotificationService) {
super(store, router, dialogRef);
if (isDefined(data.isAdd)) {
this.isAdd = data.isAdd;
}
this.stepperOrientation = this.breakpointObserver.observe(MediaBreakpoints['gt-xs'])
.pipe(map(({matches}) => matches ? 'horizontal' : 'vertical'));
this.notificationRequestForm = this.fb.group({
templateId: [null, Validators.required],
targets: [null, Validators.required]
});
}
ngOnDestroy() {
super.ngOnDestroy();
this.destroy$.next();
this.destroy$.complete();
}
cancel(): void {
this.dialogRef.close(null);
}
nextStepLabel(): string {
if (this.selectedIndex >= this.maxStepperIndex) {
return 'action.send';
}
return 'action.next';
}
changeStep($event: StepperSelectionEvent) {
this.selectedIndex = $event.selectedIndex;
if (this.selectedIndex === this.maxStepperIndex) {
this.getPreview();
}
}
backStep() {
this.createNotification.previous();
}
nextStep() {
if (this.selectedIndex >= this.maxStepperIndex) {
this.add();
} else {
this.createNotification.next();
}
}
private get maxStepperIndex(): number {
return this.createNotification?._steps?.length - 1;
}
private add(): void {
if (this.allValid()) {
this.notificationService.createNotificationRequest(this.notificationFormValue).subscribe(
(notification) => this.dialogRef.close(notification)
);
}
}
private getPreview() {
if (this.allValid()) {
this.preview = null;
this.notificationService.getNotificationRequestPreview(this.notificationFormValue).subscribe(
(preview) => this.preview = preview
);
}
}
private get notificationFormValue(): NotificationRequest {
const formValue: NotificationRequest = deepTrim(this.notificationRequestForm.value);
formValue.additionalConfig = {sendingDelayInSec: 0};
return formValue;
}
private allValid(): boolean {
return !this.createNotification.steps.find((item, index) => {
if (item.stepControl?.invalid) {
item.interacted = true;
this.createNotification.selectedIndex = index;
return true;
} else {
return false;
}
});
}
}

3
ui-ngx/src/app/modules/home/pages/notification-center/targets-table/target-notification-dialog.component.html

@ -49,6 +49,9 @@
required required
formControlName="usersIds" formControlName="usersIds"
[entityType]="entityType.USER" [entityType]="entityType.USER"
labelText="user.user-list"
placeholderText="user.user"
requiredText="user.user-list-required"
*ngIf="targetNotificationForm.get('configuration.type').value === notificationTargetConfigType.USER_LIST"> *ngIf="targetNotificationForm.get('configuration.type').value === notificationTargetConfigType.USER_LIST">
</tb-entity-list> </tb-entity-list>
<section <section

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

@ -1,74 +0,0 @@
<!--
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.
-->
<mat-toolbar color="primary">
<h2>{{ dialogTitle | translate }}</h2>
<span fxFlex></span>
<button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<form mat-dialog-content [formGroup]="templateNotificationForm">
<mat-form-field class="mat-block">
<mat-label translate>notification.target-name</mat-label>
<input matInput formControlName="name" required>
<mat-error *ngIf="templateNotificationForm.get('name').hasError('required')">
{{ 'notification.target-name-required' | translate }}
</mat-error>
</mat-form-field>
<label style="margin-bottom: 8px">{{ 'notification.type' | translate }}<sup> *</sup></label>
<mat-radio-group formControlName="type">
<mat-radio-button [value]="NotificationType.GENERIC">
<div fxLayout="column">
<div>Generic</div>
<div class="tb-hint">hint</div>
</div>
</mat-radio-button>
<mat-radio-button [value]="NotificationType.ALARM">
<div>Alarm</div>
<div class="tb-hint">hint</div>
</mat-radio-button>
</mat-radio-group>
<section formGroupName="configuration">
<mat-form-field class="mat-block">
<mat-label translate>notification.message</mat-label>
<input matInput formControlName="defaultTextTemplate" required>
<mat-error *ngIf="templateNotificationForm.get('configuration.defaultTextTemplate').hasError('required')">
{{ 'notification.message-required' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>notification.subject</mat-label>
<input matInput required>
</mat-form-field>
</section>
</form>
<mat-divider></mat-divider>
<div mat-dialog-actions fxLayout="row" fxLayoutGap="8px" fxLayoutAlign="end">
<button mat-button
[disabled]="(isLoading$ | async)"
(click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-raised-button
[disabled]="(isLoading$ | async) || templateNotificationForm.invalid || !templateNotificationForm.dirty"
color="primary"
(click)="save()">{{ saveButtonLabel | translate }}</button>
</div>

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

@ -1,95 +0,0 @@
///
/// 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 { NotificationTemplate, NotificationType } from '@shared/models/notification.models';
import { Component, Inject, OnDestroy } from '@angular/core';
import { DialogComponent } from '@shared/components/dialog.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Router } from '@angular/router';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { NotificationService } from '@core/http/notification.service';
import { deepTrim, isDefined } from '@core/utils';
import { Subject } from 'rxjs';
export interface TemplateNotificationDialogData {
template?: NotificationTemplate;
isAdd?: boolean;
isCopy?: boolean;
}
@Component({
selector: 'tb-template-notification-dialog',
templateUrl: './template-notification-dialog.component.html',
styleUrls: ['./template-notification-dialog.component.scss']
})
export class TemplateNotificationDialogComponent
extends DialogComponent<TemplateNotificationDialogComponent, NotificationTemplate> implements OnDestroy {
templateNotificationForm: FormGroup;
dialogTitle = 'notification.edit-notification-template';
saveButtonLabel = 'action.save';
NotificationType = NotificationType;
private readonly destroy$ = new Subject<void>();
constructor(protected store: Store<AppState>,
protected router: Router,
protected dialogRef: MatDialogRef<TemplateNotificationDialogComponent, NotificationTemplate>,
@Inject(MAT_DIALOG_DATA) public data: TemplateNotificationDialogData,
private fb: FormBuilder,
private notificationService: NotificationService) {
super(store, router, dialogRef);
if (data.isAdd) {
this.dialogTitle = 'notification.add-notification-template';
this.saveButtonLabel = 'action.add';
}
if (data.isCopy) {
this.dialogTitle = 'notification.copy-notification-template';
}
this.templateNotificationForm = this.fb.group({
name: [null, Validators.required],
type: [NotificationType.GENERIC],
configuration: this.fb.group({
defaultTextTemplate: [null, Validators.required]
})
});
}
ngOnDestroy() {
super.ngOnDestroy();
this.destroy$.next();
this.destroy$.complete();
}
cancel(): void {
this.dialogRef.close(null);
}
save() {
let formValue = deepTrim(this.templateNotificationForm.value);
if (isDefined(this.data.template)) {
formValue = Object.assign({}, this.data.template, formValue);
}
this.notificationService.saveNotificationTemplate(formValue).subscribe(
(target) => this.dialogRef.close(target)
);
}
}

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

@ -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.
-->
<mat-form-field [formGroup]="selectTemplateFormGroup" class="mat-block">
<input matInput type="text" placeholder="{{ 'notification.template' | translate }}"
#templateInput
formControlName="templateName"
(focusin)="onFocus()"
[required]="required"
[matAutocomplete]="templateAutocomplete">
<button *ngIf="selectTemplateFormGroup.get('templateName').value && !disabled"
type="button"
matSuffix mat-button mat-icon-button aria-label="Clear"
(click)="clear()">
<mat-icon class="material-icons">close</mat-icon>
</button>
<mat-autocomplete class="tb-autocomplete"
#templateAutocomplete="matAutocomplete"
[displayWith]="displayTemplateFn"
>
<mat-option *ngFor="let template of filteredTemplate | async" [value]="template" class="template-option">
<span class="template-option-name" [innerHTML]="template.name | highlight:searchText"></span>
<mat-chip-list>
<mat-chip disabled *ngFor="let method of template.configuration.deliveryMethodsTemplates | keyvalue">
{{ notificationDeliveryMethodTranslateMap.get(method.key) | translate }}
</mat-chip>
</mat-chip-list>
</mat-option>
<mat-option *ngIf="!(filteredTemplate | async)?.length" [value]="null" class="tb-not-found">
<div class="tb-not-found-content" (click)="$event.stopPropagation()">
<div *ngIf="!textIsNotEmpty(searchText); else searchNotEmpty">
<span translate>notification.no-notification-templates</span>
</div>
<ng-template #searchNotEmpty>
<span>Not template matching {{ truncate.transform(searchText, true, 6, &apos;...&apos;) }}</span>
</ng-template>
</div>
</mat-option>
</mat-autocomplete>
<mat-error *ngIf="selectTemplateFormGroup.get('templateName').hasError('required')">
{{ 'notification.template-required' | translate }}
</mat-error>
</mat-form-field>

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

@ -13,27 +13,29 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
:host {
width: 600px;
.tb-hint { ::ng-deep {
padding: 0; .template-option {
} .mat-option-text {
} display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 4px;
}
:host ::ng-deep { .template-option-name {
.mat-radio-group { margin-right: 8px;
display: flex; }
flex-direction: row;
.mat-radio-button { .mat-chip {
flex: 1 1 100%; &.mat-standard-chip {
padding: 14px; padding: 4px 8px;
border: 1px solid rgba(0, 0, 0, 0.12); border-radius: 14px;
border-radius: 6px; min-height: 28px;
&:not(:last-child){ &.mat-chip-disabled {
margin-right: 8px; opacity: 1;
}
} }
} }
} }

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

@ -0,0 +1,213 @@
///
/// 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, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core';
import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms';
import { Observable, of } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, map, share, switchMap, tap } from 'rxjs/operators';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { TranslateService } from '@ngx-translate/core';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { EntityId } from '@shared/models/id/entity-id';
import { EntityService } from '@core/http/entity.service';
import { TruncatePipe } from '@shared/pipe/truncate.pipe';
import { PageLink } from '@shared/models/page/page-link';
import { Direction } from '@shared/models/page/sort-order';
import { emptyPageData } from '@shared/models/page/page-data';
import { NotificationDeliveryMethodTranslateMap, NotificationTemplate } from '@shared/models/notification.models';
import { NotificationService } from '@core/http/notification.service';
import { isEqual } from '@core/utils';
@Component({
selector: 'tb-template-autocomplete',
templateUrl: './template-autocomplete.component.html',
styleUrls: ['./template-autocomplete.component.scss'],
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => TemplateAutocompleteComponent),
multi: true
}]
})
export class TemplateAutocompleteComponent implements ControlValueAccessor, OnInit {
notificationDeliveryMethodTranslateMap = NotificationDeliveryMethodTranslateMap;
selectTemplateFormGroup: FormGroup;
@Input()
labelText: string;
@Input()
requiredText: string;
private requiredValue: boolean;
get required(): boolean {
return this.requiredValue;
}
@Input()
set required(value: boolean) {
this.requiredValue = coerceBooleanProperty(value);
}
@Input()
disabled: boolean;
@ViewChild('templateInput', {static: true}) templateInput: ElementRef;
filteredTemplate: Observable<Array<NotificationTemplate>>;
searchText = '';
private modelValue: EntityId | null;
private dirty = false;
private propagateChange = (v: any) => { };
constructor(private store: Store<AppState>,
public translate: TranslateService,
public truncate: TruncatePipe,
private entityService: EntityService,
private notificationService: NotificationService,
private fb: FormBuilder) {
this.selectTemplateFormGroup = this.fb.group({
templateName: [null]
});
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
ngOnInit() {
if (this.required) {
this.selectTemplateFormGroup.get('templateName').addValidators(Validators.required);
this.selectTemplateFormGroup.get('templateName').updateValueAndValidity({emitEvent: false});
}
this.filteredTemplate = this.selectTemplateFormGroup.get('templateName').valueChanges
.pipe(
debounceTime(150),
tap(value => {
let modelValue;
if (typeof value === 'string' || !value) {
modelValue = null;
} else {
modelValue = value.id;
}
this.updateView(modelValue);
if (value === null) {
this.clear();
}
}),
map(value => value ? (typeof value === 'string' ? value : value.name) : ''),
distinctUntilChanged(),
switchMap(name => this.fetchTemplate(name)),
share()
);
}
ngAfterViewInit(): void {}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (this.disabled) {
this.selectTemplateFormGroup.disable({emitEvent: false});
} else {
this.selectTemplateFormGroup.enable({emitEvent: false});
}
}
textIsNotEmpty(text: string): boolean {
return (text && text.length > 0);
}
writeValue(value: EntityId | null): void {
this.searchText = '';
if (value != null) {
this.notificationService.getNotificationTemplateById(value.id, {ignoreLoading: true, ignoreErrors: true}).subscribe(
(entity) => {
this.modelValue = entity.id;
this.selectTemplateFormGroup.get('templateName').patchValue(entity, {emitEvent: false});
},
() => {
this.modelValue = null;
this.selectTemplateFormGroup.get('templateName').patchValue('', {emitEvent: false});
if (value !== null) {
this.propagateChange(this.modelValue);
}
}
);
} else {
this.modelValue = null;
this.selectTemplateFormGroup.get('templateName').patchValue('', {emitEvent: false});
}
this.dirty = true;
}
onFocus() {
if (this.dirty) {
this.selectTemplateFormGroup.get('templateName').updateValueAndValidity({onlySelf: true, emitEvent: true});
this.dirty = false;
}
}
reset() {
this.selectTemplateFormGroup.get('templateName').patchValue('', {emitEvent: false});
}
updateView(value: EntityId | null) {
if (!isEqual(this.modelValue, value)) {
this.modelValue = value;
this.propagateChange(this.modelValue);
}
}
displayTemplateFn(queue?: NotificationTemplate): string | undefined {
return queue ? queue.name : undefined;
}
fetchTemplate(searchText?: string): Observable<Array<NotificationTemplate>> {
this.searchText = searchText;
const pageLink = new PageLink(10, 0, searchText, {
property: 'name',
direction: Direction.ASC
});
return this.notificationService.getNotificationTemplates(pageLink, {ignoreLoading: true}).pipe(
catchError(() => of(emptyPageData<NotificationTemplate>())),
map(pageData => {
return pageData.data;
})
);
}
getDescription(value) {
return value.additionalInfo?.description ? value.additionalInfo.description :
this.translate.instant(
'queue.alt-description',
{submitStrategy: value.submitStrategy.type, processingStrategy: value.processingStrategy.type}
);
}
clear() {
this.selectTemplateFormGroup.get('templateName').patchValue('', {emitEvent: true});
setTimeout(() => {
this.templateInput.nativeElement.blur();
this.templateInput.nativeElement.focus();
}, 0);
}
}

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

@ -0,0 +1,159 @@
<!--
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.
-->
<mat-toolbar color="primary">
<h2>{{ dialogTitle | translate }}</h2>
<span fxFlex></span>
<button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<div mat-dialog-content>
<mat-horizontal-stepper [linear]="true" labelPosition="end" #addNotificationTemplate [orientation]="(stepperOrientation | async)"
(selectionChange)="changeStep($event)">
<ng-template matStepperIcon="edit">
<mat-icon>check</mat-icon>
</ng-template>
<mat-step [stepControl]="templateNotificationForm">
<ng-template matStepLabel>{{ 'notification.basic-settings' | translate }}</ng-template>
<form [formGroup]="templateNotificationForm" style="padding-bottom: 16px;">
<mat-form-field class="mat-block">
<mat-label translate>notification.target-name</mat-label>
<input matInput formControlName="name" required>
<mat-error *ngIf="templateNotificationForm.get('name').hasError('required')">
{{ 'notification.target-name-required' | translate }}
</mat-error>
</mat-form-field>
<label style="margin-bottom: 8px">{{ 'notification.type' | translate }}</label>
<mat-radio-group formControlName="notificationType">
<mat-radio-button *ngFor="let notificationType of notificationTypes" [value]="notificationType">
<div fxLayout="column">
<div>{{ notificationTemplateTypeTranslateMap.get(notificationType).name | translate }}</div>
<div class="tb-hint">{{ notificationTemplateTypeTranslateMap.get(notificationType).hint }}</div>
</div>
</mat-radio-button>
</mat-radio-group>
<section formGroupName="configuration">
<mat-form-field class="mat-block">
<mat-label translate>notification.subject</mat-label>
<input matInput formControlName="notificationSubject" required>
<mat-error *ngIf="templateNotificationForm.get('configuration.notificationSubject').hasError('required')">
{{ 'notification.subject-required' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>notification.message</mat-label>
<input matInput formControlName="defaultTextTemplate" required>
<mat-error *ngIf="templateNotificationForm.get('configuration.defaultTextTemplate').hasError('required')">
{{ 'notification.message-required' | translate }}
</mat-error>
</mat-form-field>
<label class="tb-title tb-required"
[ngClass]="{'tb-error': templateNotificationForm.get('configuration.deliveryMethodsTemplates').hasError('atLeastOne')}">
{{ "notification.delivery-methods" | translate }}
</label>
<section formGroupName="deliveryMethodsTemplates" fxLayout="row warap">
<section *ngFor="let deliveryMethods of notificationDeliveryMethods; even as isEven" class="delivery-method-container"
[formGroupName]="deliveryMethods" fxFlex="50%" [ngClass]={even:isEven}>
<mat-slide-toggle class="delivery-method" formControlName="enabled">
{{ notificationDeliveryMethodTranslateMap.get(deliveryMethods) | translate }}
</mat-slide-toggle>
</section>
</section>
<div class="tb-hint" translate>notification.at-least-one-should-be-selected</div>
</section>
</form>
</mat-step>
<mat-step optional *ngIf="templateNotificationForm.get('configuration.deliveryMethodsTemplates.PUSH.enabled').value"
[stepControl]="pushTemplateForm">
<ng-template matStepLabel>{{ 'notification.web-settings' | translate }}</ng-template>
<form [formGroup]="pushTemplateForm">
<mat-form-field class="mat-block" floatLabel="always">
<mat-label translate>notification.subject</mat-label>
<input matInput formControlName="subject" [placeholder]="templateNotificationForm.get('configuration.notificationSubject').value">
</mat-form-field>
<mat-form-field class="mat-block" floatLabel="always">
<mat-label translate>notification.message</mat-label>
<input matInput formControlName="body" [placeholder]="templateNotificationForm.get('configuration.defaultTextTemplate').value">
</mat-form-field>
</form>
</mat-step>
<mat-step optional *ngIf="templateNotificationForm.get('configuration.deliveryMethodsTemplates.EMAIL.enabled').value"
[stepControl]="emailTemplateForm">
<ng-template matStepLabel>{{ 'notification.email-settings' | translate }}</ng-template>
<ng-template matStepContent>
<form [formGroup]="emailTemplateForm">
<mat-form-field class="mat-block" floatLabel="always">
<mat-label translate>notification.subject</mat-label>
<input matInput formControlName="subject" [placeholder]="templateNotificationForm.get('configuration.notificationSubject').value">
</mat-form-field>
<mat-label class="tb-title" translate>notification.message</mat-label>
<editor [init]="tinyMceOptions" formControlName="body"></editor>
</form>
</ng-template>
</mat-step>
<mat-step optional *ngIf="templateNotificationForm.get('configuration.deliveryMethodsTemplates.SMS.enabled').value"
[stepControl]="smsTemplateForm">
<ng-template matStepLabel>{{ 'notification.sms-settings' | translate }}</ng-template>
<form [formGroup]="smsTemplateForm">
<mat-form-field class="mat-block" floatLabel="always">
<mat-label translate>notification.message</mat-label>
<input matInput formControlName="body" [placeholder]="templateNotificationForm.get('configuration.defaultTextTemplate').value">
</mat-form-field>
</form>
</mat-step>
<mat-step optional *ngIf="templateNotificationForm.get('configuration.deliveryMethodsTemplates.SLACK.enabled').value"
[stepControl]="slackTemplateForm">
<ng-template matStepLabel>{{ 'notification.slack-settings' | translate }}</ng-template>
<form [formGroup]="slackTemplateForm">
<mat-form-field class="mat-block" floatLabel="always">
<mat-label translate>notification.message</mat-label>
<input matInput formControlName="body" [placeholder]="templateNotificationForm.get('configuration.defaultTextTemplate').value">
</mat-form-field>
<mat-form-field class="mat-block" floatLabel="always">
<mat-label>Conversation type</mat-label>
<input matInput formControlName="conversationType">
</mat-form-field>
<mat-form-field class="mat-block" floatLabel="always">
<mat-label>Conversation id</mat-label>
<input matInput formControlName="conversationId" required>
<mat-error *ngIf="slackTemplateForm.get('conversationId').hasError('required')">
{{ 'notification.subject-required' | translate }}
</mat-error>
</mat-form-field>
</form>
</mat-step>
</mat-horizontal-stepper>
</div>
<mat-divider></mat-divider>
<div mat-dialog-actions fxLayout="row">
<button mat-stroked-button *ngIf="selectedIndex > 0"
(click)="backStep()">{{ 'action.back' | translate }}</button>
<span fxFlex></span>
<button *ngIf="selectedIndex > 0"
mat-button mat-stroked-button color="primary">
{{ 'notification.send-test-notification' | translate }}
</button>
<button mat-raised-button
color="primary"
(click)="nextStep()">{{ nextStepLabel() | translate }}</button>
</div>

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

@ -0,0 +1,101 @@
/**
* 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 "../../../../../../scss/constants";
:host-context(.tb-fullscreen-dialog .mat-dialog-container) {
width: 720px;
.tb-hint {
padding: 0;
}
.delivery-method-container {
padding-bottom: 8px;
&.even {
padding-right: 8px;
}
.delivery-method {
padding: 16px 12px;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 6px;
width: 100%;
height: 100%;
}
}
@media #{$mat-gt-xs} {
.mat-dialog-content {
max-height: 75vh;
}
}
}
:host ::ng-deep {
.mat-dialog-content {
display: flex;
flex-direction: column;
height: 100%;
padding: 0 !important;
.mat-stepper-horizontal {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
@media #{$mat-lt-sm} {
.mat-step-label {
white-space: normal;
overflow: visible;
.mat-step-text-label {
overflow: visible;
}
}
}
.mat-horizontal-content-container {
height: 500px;
max-height: 100%;
width: 100%;;
overflow-y: auto;
scrollbar-gutter: stable;
@media #{$mat-gt-sm} {
min-width: 500px;
}
}
.mat-horizontal-stepper-content[aria-expanded=true] {
height: 100%;
position: relative;
}
}
}
.mat-radio-group {
display: flex;
flex-direction: row;
.mat-radio-button {
flex: 1 1 100%;
padding: 14px;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 6px;
&:not(:last-child){
margin-right: 8px;
}
}
}
}

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

@ -0,0 +1,255 @@
///
/// 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 {
NotificationDeliveryMethod,
NotificationDeliveryMethodTranslateMap,
NotificationTemplate,
NotificationTemplateTypeTranslateMap,
NotificationType,
SlackChanelType
} from '@shared/models/notification.models';
import { Component, Inject, OnDestroy, ViewChild } from '@angular/core';
import { DialogComponent } from '@shared/components/dialog.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Router } from '@angular/router';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { FormBuilder, FormGroup, ValidationErrors, Validators } from '@angular/forms';
import { NotificationService } from '@core/http/notification.service';
import { deepTrim, isDefined } from '@core/utils';
import { Observable, Subject } from 'rxjs';
import { map } from 'rxjs/operators';
import { StepperOrientation, StepperSelectionEvent } from '@angular/cdk/stepper';
import { MatStepper } from '@angular/material/stepper';
import { BreakpointObserver } from '@angular/cdk/layout';
import { MediaBreakpoints } from '@shared/models/constants';
export interface TemplateNotificationDialogData {
template?: NotificationTemplate;
isAdd?: boolean;
isCopy?: boolean;
}
@Component({
selector: 'tb-template-notification-dialog',
templateUrl: './template-notification-dialog.component.html',
styleUrls: ['./template-notification-dialog.component.scss']
})
export class TemplateNotificationDialogComponent
extends DialogComponent<TemplateNotificationDialogComponent, NotificationTemplate> implements OnDestroy {
@ViewChild('addNotificationTemplate', {static: true}) addNotificationTemplate: MatStepper;
stepperOrientation: Observable<StepperOrientation>;
templateNotificationForm: FormGroup;
pushTemplateForm: FormGroup;
emailTemplateForm: FormGroup;
smsTemplateForm: FormGroup;
slackTemplateForm: FormGroup;
dialogTitle = 'notification.edit-notification-template';
saveButtonLabel = 'action.save';
notificationTypes = Object.keys(NotificationType) as NotificationType[];
notificationDeliveryMethods = Object.keys(NotificationDeliveryMethod) as NotificationDeliveryMethod[];
notificationTemplateTypeTranslateMap = NotificationTemplateTypeTranslateMap;
notificationDeliveryMethodTranslateMap = NotificationDeliveryMethodTranslateMap;
selectedIndex = 0;
tinyMceOptions: Record<string, any>;
private readonly destroy$ = new Subject<void>();
constructor(protected store: Store<AppState>,
protected router: Router,
protected dialogRef: MatDialogRef<TemplateNotificationDialogComponent, NotificationTemplate>,
@Inject(MAT_DIALOG_DATA) public data: TemplateNotificationDialogData,
private breakpointObserver: BreakpointObserver,
private fb: FormBuilder,
private notificationService: NotificationService) {
super(store, router, dialogRef);
if (data.isAdd) {
this.dialogTitle = 'notification.add-notification-template';
this.saveButtonLabel = 'action.add';
}
if (data.isCopy) {
this.dialogTitle = 'notification.copy-notification-template';
}
this.tinyMceOptions = {
base_url: '/assets/tinymce',
suffix: '.min',
plugins: ['link table image imagetools code fullscreen'],
menubar: 'edit insert tools view format table',
toolbar: 'fontselect fontsizeselect | formatselect | bold italic strikethrough forecolor backcolor ' +
'| link | table | image | alignleft aligncenter alignright alignjustify ' +
'| numlist bullist outdent indent | removeformat | code | fullscreen',
height: 400,
autofocus: false,
branding: false
};
this.stepperOrientation = this.breakpointObserver.observe(MediaBreakpoints['gt-xs'])
.pipe(map(({matches}) => matches ? 'horizontal' : 'vertical'));
this.templateNotificationForm = this.fb.group({
name: ['', Validators.required],
notificationType: [NotificationType.GENERAL],
configuration: this.fb.group({
notificationSubject: ['', Validators.required],
defaultTextTemplate: ['', Validators.required],
deliveryMethodsTemplates: this.fb.group({}, {validators: this.atLeastOne()})
})
});
this.notificationDeliveryMethods.forEach(method => {
(this.templateNotificationForm.get('configuration.deliveryMethodsTemplates') as FormGroup)
.addControl(method, this.fb.group({method, enabled: method === NotificationDeliveryMethod.PUSH}), {emitEvent: false});
});
this.pushTemplateForm = this.fb.group({
subject: [''],
body: [''],
icon: [''],
actionButtonConfig: ['']
});
this.emailTemplateForm = this.fb.group({
subject: [''],
body: ['']
});
this.smsTemplateForm = this.fb.group({
body: ['']
});
this.slackTemplateForm = this.fb.group({
body: [''],
conversationId: ['', Validators.required],
conversationType: [SlackChanelType.DIRECT]
});
}
ngOnDestroy() {
super.ngOnDestroy();
this.destroy$.next();
this.destroy$.complete();
}
cancel(): void {
this.dialogRef.close(null);
}
save() {
let formValue = deepTrim(this.templateNotificationForm.value);
if (isDefined(this.data.template)) {
formValue = Object.assign({}, this.data.template, formValue);
}
this.notificationService.saveNotificationTemplate(formValue).subscribe(
(target) => this.dialogRef.close(target)
);
}
changeStep($event: StepperSelectionEvent) {
this.selectedIndex = $event.selectedIndex;
}
backStep() {
this.addNotificationTemplate.previous();
}
nextStep() {
if (this.selectedIndex >= this.maxStepperIndex) {
this.add();
} else {
this.addNotificationTemplate.next();
}
}
nextStepLabel(): string {
if (this.selectedIndex === 1 && this.selectedIndex < this.maxStepperIndex && this.pushTemplateForm.pristine) {
return 'action.skip';
}
if (this.selectedIndex === 2 && this.selectedIndex < this.maxStepperIndex && this.emailTemplateForm.pristine) {
return 'action.skip';
}
if (this.selectedIndex === 3 && this.selectedIndex < this.maxStepperIndex && this.smsTemplateForm.pristine) {
return 'action.skip';
}
if (this.selectedIndex !== 0 && this.selectedIndex >= this.maxStepperIndex) {
return 'action.add';
}
return 'action.next';
}
private get maxStepperIndex(): number {
return this.addNotificationTemplate?._steps?.length - 1;
}
private add(): void {
if (this.allValid()) {
const formValue: NotificationTemplate = this.templateNotificationForm.value;
if (formValue.configuration.deliveryMethodsTemplates.PUSH.enabled) {
Object.assign(formValue.configuration.deliveryMethodsTemplates.PUSH, this.pushTemplateForm.value);
} else {
delete formValue.configuration.deliveryMethodsTemplates.PUSH;
}
if (formValue.configuration.deliveryMethodsTemplates.EMAIL.enabled) {
Object.assign(formValue.configuration.deliveryMethodsTemplates.EMAIL, this.emailTemplateForm.value);
} else {
delete formValue.configuration.deliveryMethodsTemplates.EMAIL;
}
if (formValue.configuration.deliveryMethodsTemplates.SMS.enabled) {
Object.assign(formValue.configuration.deliveryMethodsTemplates.SMS, this.smsTemplateForm.value);
} else {
delete formValue.configuration.deliveryMethodsTemplates.SMS;
}
if (formValue.configuration.deliveryMethodsTemplates.SLACK.enabled) {
Object.assign(formValue.configuration.deliveryMethodsTemplates.SLACK, this.slackTemplateForm.value);
} else {
delete formValue.configuration.deliveryMethodsTemplates.SLACK;
}
this.notificationService.saveNotificationTemplate(deepTrim(formValue)).subscribe(
(target) => this.dialogRef.close(target)
);
}
}
private allValid(): boolean {
return !this.addNotificationTemplate.steps.find((item, index) => {
if (item.stepControl.invalid) {
item.interacted = true;
this.addNotificationTemplate.selectedIndex = index;
return true;
} else {
return false;
}
});
}
private atLeastOne() {
return (group: FormGroup): ValidationErrors | null => {
let hasAtLeastOne = true;
if (group?.controls) {
const controlsFormValue: FormGroup[] = Object.entries(group.controls).map(method => method[1]) as any;
hasAtLeastOne = controlsFormValue.some(value => value.controls.enabled.value);
}
return hasAtLeastOne ? null : {atLeastOne: true};
};
}
}

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

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

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

9
ui-ngx/src/app/shared/components/entity/entity-list.component.html

@ -15,8 +15,9 @@
limitations under the License. limitations under the License.
--> -->
<mat-form-field appearance="standard" [formGroup]="entityListFormGroup" class="mat-block"> <mat-form-field appearance="standard" [formGroup]="entityListFormGroup" class="mat-block" floatLabel="always">
<mat-chip-list #chipList formControlName="entities"> <mat-label *ngIf="labelText">{{ labelText | translate }}</mat-label>
<mat-chip-list #chipList formControlName="entities" [required]="required">
<mat-chip <mat-chip
*ngFor="let entity of entities" *ngFor="let entity of entities"
[selectable]="!disabled" [selectable]="!disabled"
@ -25,7 +26,7 @@
{{entity.name}} {{entity.name}}
<mat-icon matChipRemove *ngIf="!disabled">close</mat-icon> <mat-icon matChipRemove *ngIf="!disabled">close</mat-icon>
</mat-chip> </mat-chip>
<input matInput type="text" placeholder="{{ !disabled ? ('entity.entity-list' | translate) : '' }}" <input matInput type="text" placeholder="{{ !disabled ? (placeholderText | translate) : '' }}"
style="max-width: 200px;" style="max-width: 200px;"
#entityInput #entityInput
(focusin)="onFocus()" (focusin)="onFocus()"
@ -49,6 +50,6 @@
</mat-option> </mat-option>
</mat-autocomplete> </mat-autocomplete>
<mat-error *ngIf="entityListFormGroup.get('entities').hasError('required')"> <mat-error *ngIf="entityListFormGroup.get('entities').hasError('required')">
{{ 'entity.entity-list-empty' | translate }} {{ requiredText | translate }}
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>

9
ui-ngx/src/app/shared/components/entity/entity-list.component.ts

@ -63,6 +63,15 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV
@Input() @Input()
subType: string; subType: string;
@Input()
labelText: string;
@Input()
placeholderText = 'entity.entity-list';
@Input()
requiredText = 'entity.entity-list-empty';
private requiredValue: boolean; private requiredValue: boolean;
get required(): boolean { get required(): boolean {
return this.requiredValue; return this.requiredValue;

2
ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.html

@ -32,7 +32,7 @@
#queueAutocomplete="matAutocomplete" #queueAutocomplete="matAutocomplete"
[displayWith]="displayQueueFn" [displayWith]="displayQueueFn"
> >
<mat-option *ngFor="let queue of filteredQueues | async" [value]="queue" class="queue-option"> <mat-option *ngFor="let queue of filteredQueues | async" [value]="queue" class="template-option">
<span [innerHTML]="queue.name | highlight:searchText"></span> <span [innerHTML]="queue.name | highlight:searchText"></span>
<small class="queue-option-description">{{getDescription(queue)}}</small> <small class="queue-option-description">{{getDescription(queue)}}</small>
</mat-option> </mat-option>

2
ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.scss

@ -15,7 +15,7 @@
*/ */
::ng-deep { ::ng-deep {
.queue-option { .template-option {
.mat-option-text { .mat-option-text {
display: inline; display: inline;
} }

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

@ -29,7 +29,8 @@ export interface Notification {
readonly id: NotificationId; readonly id: NotificationId;
readonly requestId: NotificationRequestId; readonly requestId: NotificationRequestId;
readonly recipientId: UserId; readonly recipientId: UserId;
readonly type: string; readonly type: NotificationType;
readonly subject: string;
readonly text: string; readonly text: string;
readonly info: NotificationInfo; readonly info: NotificationInfo;
readonly originatorType: NotificationOriginatorType; readonly originatorType: NotificationOriginatorType;
@ -44,7 +45,7 @@ export interface NotificationInfo {
export interface NotificationRequest extends Omit<BaseData<NotificationRequestId>, 'label'> { export interface NotificationRequest extends Omit<BaseData<NotificationRequestId>, 'label'> {
tenantId?: TenantId; tenantId?: TenantId;
targets: Array<NotificationTargetId>; targets: Array<string>;
templateId: NotificationTemplateId; templateId: NotificationTemplateId;
info?: NotificationInfo; info?: NotificationInfo;
deliveryMethods: Array<NotificationDeliveryMethod>; deliveryMethods: Array<NotificationDeliveryMethod>;
@ -55,6 +56,17 @@ export interface NotificationRequest extends Omit<BaseData<NotificationRequestId
additionalConfig: NotificationRequestConfig; additionalConfig: NotificationRequestConfig;
} }
export interface NotificationRequestInfo extends NotificationRequest {
templateName: string;
deliveryMethods: NotificationDeliveryMethod[];
}
export interface NotificationRequestPreview {
totalRecipientsCount: number;
recipientsCountByTarget: { [key in string]: number };
processedTemplates: { [key in NotificationDeliveryMethod]: DeliveryMethodNotificationTemplate };
}
export interface NotificationRequestStats { export interface NotificationRequestStats {
sent: Map<NotificationDeliveryMethod, any>; sent: Map<NotificationDeliveryMethod, any>;
errors: Map<NotificationDeliveryMethod, Map<string, string>>; errors: Map<NotificationDeliveryMethod, Map<string, string>>;
@ -128,12 +140,16 @@ export interface NotificationTemplate extends Omit<BaseData<NotificationTemplate
interface NotificationTemplateConfig { interface NotificationTemplateConfig {
defaultTextTemplate: string; defaultTextTemplate: string;
deliveryMethodsTemplates: Map<NotificationDeliveryMethod, DeliveryMethodNotificationTemplate>; notificationSubject: string;
deliveryMethodsTemplates: {
[key in NotificationDeliveryMethod]: DeliveryMethodNotificationTemplate
};
} }
interface DeliveryMethodNotificationTemplate extends export interface DeliveryMethodNotificationTemplate extends
Partial<PushDeliveryMethodNotificationTemplate & EmailDeliveryMethodNotificationTemplate & SlackDeliveryMethodNotificationTemplate>{ Partial<PushDeliveryMethodNotificationTemplate & EmailDeliveryMethodNotificationTemplate & SlackDeliveryMethodNotificationTemplate>{
body?: string; body?: string;
enabled: boolean;
method: NotificationDeliveryMethod; method: NotificationDeliveryMethod;
} }
@ -170,13 +186,27 @@ export enum NotificationDeliveryMethod {
SLACK = 'SLACK' SLACK = 'SLACK'
} }
export const NotificationDeliveryMethodTranslateMap = new Map<NotificationDeliveryMethod, string>([
[NotificationDeliveryMethod.PUSH, 'notification.delivery-method-type.push'],
[NotificationDeliveryMethod.SMS, 'notification.delivery-method-type.sms'],
[NotificationDeliveryMethod.EMAIL, 'notification.delivery-method-type.email'],
[NotificationDeliveryMethod.SLACK, 'notification.delivery-method-type.slack'],
]);
export enum NotificationRequestStatus { export enum NotificationRequestStatus {
PROCESSED = 'PROCESSED', PROCESSING = 'PROCESSING',
SCHEDULED = 'SCHEDULED' SCHEDULED = 'SCHEDULED',
SENT = 'SENT'
} }
export const NotificationRequestStatusTranslateMap = new Map<NotificationRequestStatus, string>([
[NotificationRequestStatus.PROCESSING, 'notification.requet-status.processing'],
[NotificationRequestStatus.SCHEDULED, 'notification.requet-status.scheduled'],
[NotificationRequestStatus.SENT, 'notification.requet-status.sent']
]);
export enum SlackChanelType { export enum SlackChanelType {
USER= 'USER', DIRECT= 'DIRECT',
PUBLIC_CHANNEL = 'PUBLIC_CHANNEL', PUBLIC_CHANNEL = 'PUBLIC_CHANNEL',
PRIVATE_CHANNEL = 'PRIVATE_CHANNEL' PRIVATE_CHANNEL = 'PRIVATE_CHANNEL'
} }
@ -194,6 +224,25 @@ export const NotificationTargetConfigTypeTranslateMap = new Map<NotificationTarg
]); ]);
export enum NotificationType { export enum NotificationType {
GENERIC = 'GENERIC', GENERAL = 'GENERAL',
ALARM = 'ALARM' ALARM = 'ALARM'
} }
interface NotificationTemplateTypeTranslate {
name: string;
hint?: string;
}
export const NotificationTemplateTypeTranslateMap = new Map<NotificationType, NotificationTemplateTypeTranslate>([
[NotificationType.GENERAL,
{
name: 'notification.template-type.general',
hint: 'hint'
}
],
[NotificationType.ALARM,
{
name: 'notification.template-type.alarm',
hint: 'hint'
}]
]);

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

@ -64,6 +64,7 @@ import { ShareModule as ShareButtonsModule } from 'ngx-sharebuttons';
import { HotkeyModule } from 'angular2-hotkeys'; import { HotkeyModule } from 'angular2-hotkeys';
import { ColorPickerModule } from 'ngx-color-picker'; import { ColorPickerModule } from 'ngx-color-picker';
import { NgxHmCarouselModule } from 'ngx-hm-carousel'; import { NgxHmCarouselModule } from 'ngx-hm-carousel';
import { EditorModule, TINYMCE_SCRIPT_SRC } from '@tinymce/tinymce-angular';
import { UserMenuComponent } from '@shared/components/user-menu.component'; import { UserMenuComponent } from '@shared/components/user-menu.component';
import { NospacePipe } from '@shared/pipe/nospace.pipe'; import { NospacePipe } from '@shared/pipe/nospace.pipe';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
@ -188,6 +189,10 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
provide: FlowInjectionToken, provide: FlowInjectionToken,
useValue: Flow useValue: Flow
}, },
{
provide: TINYMCE_SCRIPT_SRC,
useValue: 'assets/tinymce/tinymce.min.js'
},
{ {
provide: MAT_DATE_LOCALE, provide: MAT_DATE_LOCALE,
useValue: 'en-GB' useValue: 'en-GB'
@ -457,6 +462,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
HotkeyModule, HotkeyModule,
ColorPickerModule, ColorPickerModule,
NgxHmCarouselModule, NgxHmCarouselModule,
EditorModule,
DndModule, DndModule,
NgxFlowchartModule, NgxFlowchartModule,
MarkdownModule, MarkdownModule,

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

@ -55,13 +55,16 @@
"continue": "Continue", "continue": "Continue",
"discard-changes": "Discard Changes", "discard-changes": "Discard Changes",
"download": "Download", "download": "Download",
"next": "Next",
"next-with-label": "Next: {{label}}", "next-with-label": "Next: {{label}}",
"read-more": "Read more", "read-more": "Read more",
"hide": "Hide", "hide": "Hide",
"done": "Done", "done": "Done",
"print": "Print", "print": "Print",
"restore": "Restore", "restore": "Restore",
"confirm": "Confirm" "confirm": "Confirm",
"skip": "Skip",
"send": "Send"
}, },
"aggregation": { "aggregation": {
"aggregation": "Aggregation", "aggregation": "Aggregation",
@ -2670,33 +2673,66 @@
"add-notification-template": "Add notification template", "add-notification-template": "Add notification template",
"add-target": "Add target", "add-target": "Add target",
"add-template": "Add template", "add-template": "Add template",
"at-least-one-should-be-selected": "At least one should be selected",
"all": "All",
"basic-settings": "Basic settings",
"compose": "Compose",
"copy-notification-template": "Copy notification template", "copy-notification-template": "Copy notification template",
"create-target": "Create target", "create-target": "Create target",
"created-time": "Created time", "created-time": "Created time",
"delete-request-text": "Be careful, after the confirmation the notification request will become unrecoverable.",
"delete-request-title": "Are you sure you want to delete the notification request?",
"delete-target-text": "Be careful, after the confirmation the notification target will become unrecoverable.", "delete-target-text": "Be careful, after the confirmation the notification target will become unrecoverable.",
"delete-target-title": "Are you sure you want to delete the notification target '{{targetName}}'?", "delete-target-title": "Are you sure you want to delete the notification target '{{targetName}}'?",
"delete-template-text": "Be careful, after the confirmation the notification template will become unrecoverable.", "delete-template-text": "Be careful, after the confirmation the notification template will become unrecoverable.",
"delete-template-title": "Are you sure you want to delete the notification template '{{templateName}}'?", "delete-template-title": "Are you sure you want to delete the notification template '{{templateName}}'?",
"delivery-method": "Delivery method",
"delivery-methods": "Delivery methods",
"delivery-method-type": {
"email": "Email",
"push": "Web",
"slack": "Slack",
"sms": "SMS"
},
"description": "Description", "description": "Description",
"edit-notification-target": "Edit notification target", "edit-notification-target": "Edit notification target",
"edit-notification-template": "Edit notification template", "edit-notification-template": "Edit notification template",
"email-settings": "Email settings",
"get-customer-id-from-originator": "Get Customer id from originator", "get-customer-id-from-originator": "Get Customer id from originator",
"inbox": "Inbox", "inbox": "Inbox",
"management": "Notification management", "management": "Notification management",
"mark-all-read": "Mark all read",
"mark-as-read": "Mark as read",
"message": "Message", "message": "Message",
"message-required": "Message is required", "message-required": "Message is required",
"new-notification": "New notification",
"no-inbox-notification": "No notification found", "no-inbox-notification": "No notification found",
"no-notification-request": "No notification request",
"no-notification-templates": "No notification templates", "no-notification-templates": "No notification templates",
"no-targets-notification": "No targets notification", "no-targets-notification": "No targets notification",
"notification-center": "Notification center", "notification-center": "Notification center",
"notification-target": "Notification target", "notification-target": "Notification target",
"recipients-count": "{ count, plural, 1 {1 Recipient} other {# Recipients} }",
"requet-status": {
"processing": "Processing",
"scheduled": "Scheduled",
"sent": "Sent"
},
"review": "Review",
"rules": "Rules", "rules": "Rules",
"search-notification": "Search notification",
"search-targets": "Search targets", "search-targets": "Search targets",
"search-templates": "Search templates", "search-templates": "Search templates",
"send-notification": "Send notification", "send-notification": "Send notification",
"send-test-notification": "Send test notification",
"sent": "Sent", "sent": "Sent",
"settings": "Notification settings", "settings": "Notification settings",
"slack-settings": "Slack settings",
"sms-settings": "SMS settings",
"status": "Status",
"subject": "Subject", "subject": "Subject",
"subject-required": "Subject is required",
"target": "Target",
"target-name": "Name", "target-name": "Name",
"target-name-required": "Name is required", "target-name-required": "Name is required",
"target-type": { "target-type": {
@ -2706,10 +2742,18 @@
"user-list": "User list" "user-list": "User list"
}, },
"targets": "Targets", "targets": "Targets",
"targets-required": "Targets is required",
"template": "Template", "template": "Template",
"template-required": "Template is required",
"template-type": {
"alarm": "Alarm",
"general": "General"
},
"templates": "Templates", "templates": "Templates",
"text": "Text", "text": "Text",
"type": "Type" "type": "Type",
"unread": "Unread",
"web-settings": "Web settings"
}, },
"ota-update": { "ota-update": {
"add": "Add package", "add": "Add package",
@ -3485,7 +3529,9 @@
"enable-account-message": "User account was successfully enabled!", "enable-account-message": "User account was successfully enabled!",
"disable-account-message": "User account was successfully disabled!", "disable-account-message": "User account was successfully disabled!",
"copyId": "Copy user Id", "copyId": "Copy user Id",
"idCopiedMessage": "User Id has been copied to clipboard" "idCopiedMessage": "User Id has been copied to clipboard",
"user-list": "User list",
"user-list-required": "User list is required"
}, },
"value": { "value": {
"type": "Value type", "type": "Value type",

12
ui-ngx/yarn.lock

@ -1641,6 +1641,13 @@
"@angular-devkit/schematics" "12.2.17" "@angular-devkit/schematics" "12.2.17"
jsonc-parser "3.0.0" jsonc-parser "3.0.0"
"@tinymce/tinymce-angular@~4.2.4":
version "4.2.4"
resolved "https://registry.yarnpkg.com/@tinymce/tinymce-angular/-/tinymce-angular-4.2.4.tgz#45974f39fcdb1af6f30dba6405bfb0b8a3db748f"
integrity sha512-A+FIvhTahHaqDuvcXovuhzvpCR2yjUbPuF+Z/t/MnNaCv/9Y0w7W5Qgmo5wARIW+NM7o8msWcMSxOMK4xkv10Q==
dependencies:
tslib "^1.10.0"
"@tootallnate/once@1": "@tootallnate/once@1":
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82"
@ -9582,6 +9589,11 @@ tinycolor2@^1.4.2:
resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.2.tgz#3f6a4d1071ad07676d7fa472e1fac40a719d8803" resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.2.tgz#3f6a4d1071ad07676d7fa472e1fac40a719d8803"
integrity sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA== integrity sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA==
tinymce@~5.10.7:
version "5.10.7"
resolved "https://registry.yarnpkg.com/tinymce/-/tinymce-5.10.7.tgz#d89d446f1962f2a1df6b2b70018ce475ec7ffb80"
integrity sha512-9UUjaO0R7FxcFo0oxnd1lMs7H+D0Eh+dDVo5hKbVe1a+VB0nit97vOqlinj+YwgoBDt6/DSCUoWqAYlLI8BLYA==
tmp@0.0.30: tmp@0.0.30:
version "0.0.30" version "0.0.30"
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.30.tgz#72419d4a8be7d6ce75148fd8b324e593a711c2ed" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.30.tgz#72419d4a8be7d6ce75148fd8b324e593a711c2ed"

Loading…
Cancel
Save