diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts index d4cd4ad24f..e3ee10bbc4 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/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 { QueueService } from '@core/http/queue.service'; import { AssetProfileService } from '@core/http/asset-profile.service'; +import { NotificationService } from '@core/http/notification.service'; @Injectable({ providedIn: 'root' @@ -113,7 +114,8 @@ export class EntityService { private deviceProfileService: DeviceProfileService, private assetProfileService: AssetProfileService, private utils: UtilsService, - private queueService: QueueService + private queueService: QueueService, + private notificationService: NotificationService ) { } private getEntityObservable(entityType: EntityType, entityId: string, @@ -402,6 +404,10 @@ export class EntityService { pageLink.sortOrder.property = 'title'; entitiesObservable = this.widgetService.getWidgetBundles(pageLink, config); break; + case EntityType.NOTIFICATION_TARGET: + pageLink.sortOrder.property = 'name'; + entitiesObservable = this.notificationService.getNotificationTargets(pageLink, config); + break; } return entitiesObservable; } diff --git a/ui-ngx/src/app/core/http/notification.service.ts b/ui-ngx/src/app/core/http/notification.service.ts index 93e6f0ab22..7f3bf58802 100644 --- a/ui-ngx/src/app/core/http/notification.service.ts +++ b/ui-ngx/src/app/core/http/notification.service.ts @@ -23,6 +23,7 @@ import { PageData } from '@shared/models/page/page-data'; import { Notification, NotificationRequest, + NotificationRequestPreview, NotificationRule, NotificationSettings, NotificationTarget, @@ -42,15 +43,16 @@ export class NotificationService { ) { } - public getNotifications(pageLink: PageLink, config?: RequestConfig): Observable> { - return this.http.get>(`/api/notifications${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)); + public getNotifications(pageLink: PageLink, unreadOnly = false, config?: RequestConfig): Observable> { + return this.http.get>(`/api/notifications${pageLink.toQuery()}&unreadOnly=${unreadOnly}`, + defaultHttpOptionsFromConfig(config)); } public markNotificationAsRead(id: string, config?: RequestConfig): Observable { return this.http.put(`/api/notification/${id}/read`, defaultHttpOptionsFromConfig(config)); } - public createNotificationRequest(notification: NotificationRequest, config?: RequestConfig): Observable { + public createNotificationRequest(notification: NotificationRequest, config?: RequestConfig): Observable { return this.http.post('/api/notification/request', notification, defaultHttpOptionsFromConfig(config)); } @@ -62,8 +64,14 @@ export class NotificationService { return this.http.delete(`/api/notification/request/${id}`, defaultHttpOptionsFromConfig(config)); } + public getNotificationRequestPreview(notification: NotificationRequest, config?: RequestConfig): Observable { + return this.http.post('/api/notification/request/preview', + notification, defaultHttpOptionsFromConfig(config)); + } + public getNotificationRequests(pageLink: PageLink, config?: RequestConfig): Observable> { - return this.http.get>('/api/notification/request/', defaultHttpOptionsFromConfig(config)); + return this.http.get>(`/api/notification/requests${pageLink.toQuery()}`, + defaultHttpOptionsFromConfig(config)); } public getNotificationSettings(config?: RequestConfig): Observable { diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/inbox-table/inbox-table-header.component.html b/ui-ngx/src/app/modules/home/pages/notification-center/inbox-table/inbox-table-header.component.html new file mode 100644 index 0000000000..aa34aed8ff --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification-center/inbox-table/inbox-table-header.component.html @@ -0,0 +1,27 @@ + +
+ + Unread + {{ 'notification.all' | translate }} + + +
diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/inbox-table/inbox-table-header.component.scss b/ui-ngx/src/app/modules/home/pages/notification-center/inbox-table/inbox-table-header.component.scss new file mode 100644 index 0000000000..69b772f491 --- /dev/null +++ b/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; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/inbox-table/inbox-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/notification-center/inbox-table/inbox-table-header.component.ts new file mode 100644 index 0000000000..32a41fdadf --- /dev/null +++ b/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 { + + constructor(protected store: Store) { + super(store); + } + + changeUnreadOnly(unreadOnly: boolean) { + this.entitiesTableConfig.componentsData.unreadOnly = unreadOnly; + this.entitiesTableConfig.getTable().resetSortAndFilter(true); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/notification-center.component.html b/ui-ngx/src/app/modules/home/pages/notification-center/notification-center.component.html index fb338ec54d..b81aa6cee6 100644 --- a/ui-ngx/src/app/modules/home/pages/notification-center/notification-center.component.html +++ b/ui-ngx/src/app/modules/home/pages/notification-center/notification-center.component.html @@ -41,6 +41,7 @@ @@ -62,7 +63,7 @@
- + + + +
+
+ + + check + + + {{ 'notification.compose' | translate }} +
+ + + + +
+
+ + {{ 'notification.review' | translate }} + + +
+
+
+ supervisor_account +
{{ 'notification.recipients-count' | translate : {count: preview.totalRecipientsCount} }}
+
+
+
+
+
+
+ +
+ + + +
diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/request-table/request-notification-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/notification-center/request-table/request-notification-dialog.component.scss new file mode 100644 index 0000000000..bd49595bbd --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification-center/request-table/request-notification-dialog.component.scss @@ -0,0 +1,81 @@ +/** + * 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; + } + } + + @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; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/request-table/request-notification-dialog.componet.ts b/ui-ngx/src/app/modules/home/pages/notification-center/request-table/request-notification-dialog.componet.ts new file mode 100644 index 0000000000..53e6078a9e --- /dev/null +++ b/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 implements OnDestroy { + + @ViewChild('createNotification', {static: true}) createNotification: MatStepper; + stepperOrientation: Observable; + isAdd = true; + entityType = EntityType; + notificationRequestForm: FormGroup; + + selectedIndex = 0; + preview: NotificationRequestPreview = null; + + private readonly destroy$ = new Subject(); + + constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @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; + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/targets-table/target-notification-dialog.component.html b/ui-ngx/src/app/modules/home/pages/notification-center/targets-table/target-notification-dialog.component.html index 5611cc278a..7d03645d31 100644 --- a/ui-ngx/src/app/modules/home/pages/notification-center/targets-table/target-notification-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/notification-center/targets-table/target-notification-dialog.component.html @@ -49,6 +49,9 @@ required formControlName="usersIds" [entityType]="entityType.USER" + labelText="user.user-list" + placeholderText="user.user" + requiredText="user.user-list-required" *ngIf="targetNotificationForm.get('configuration.type').value === notificationTargetConfigType.USER_LIST">
+ + + + + + + + + {{ notificationDeliveryMethodTranslateMap.get(method.key) | translate }} + + + + +
+
+ notification.no-notification-templates +
+ + Not template matching {{ truncate.transform(searchText, true, 6, '...') }} + +
+
+
+ + {{ 'notification.template-required' | translate }} + +
diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-autocomplete.component.scss b/ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-autocomplete.component.scss new file mode 100644 index 0000000000..e0ff2f406a --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-autocomplete.component.scss @@ -0,0 +1,42 @@ +/** + * 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. + */ + +::ng-deep { + .template-option { + .mat-option-text { + display: flex; + flex-direction: row; + align-items: center; + margin-bottom: 4px; + } + + .template-option-name { + margin-right: 8px; + } + + .mat-chip { + &.mat-standard-chip { + padding: 4px 8px; + border-radius: 14px; + min-height: 28px; + + &.mat-chip-disabled { + opacity: 1; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-autocomplete.component.ts b/ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-autocomplete.component.ts new file mode 100644 index 0000000000..b478718758 --- /dev/null +++ b/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>; + + searchText = ''; + + private modelValue: EntityId | null; + private dirty = false; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + 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> { + 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())), + 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); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-notification-dialog.component.html b/ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-notification-dialog.component.html index 61d8876fb4..c6eaec492d 100644 --- a/ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-notification-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-notification-dialog.component.html @@ -27,7 +27,7 @@
-
+
@@ -67,14 +67,19 @@ {{ 'notification.message-required' | translate }} - +
- {{ deliveryMethods }} + + {{ notificationDeliveryMethodTranslateMap.get(deliveryMethods) | translate }} +
-
At least one should be selected
+
notification.at-least-one-should-be-selected
diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-notification-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-notification-dialog.component.scss index 57e8170cac..a964169756 100644 --- a/ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-notification-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-notification-dialog.component.scss @@ -1,3 +1,18 @@ +/** + * 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) { @@ -19,6 +34,7 @@ border: 1px solid rgba(0, 0, 0, 0.12); border-radius: 6px; width: 100%; + height: 100%; } } diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-notification-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-notification-dialog.component.ts index 7119e7dbd4..594ff9d3a0 100644 --- a/ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-notification-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/notification-center/template-table/template-notification-dialog.component.ts @@ -15,11 +15,12 @@ /// import { - DeliveryMethodNotificationTemplate, NotificationDeliveryMethod, + NotificationDeliveryMethodTranslateMap, NotificationTemplate, - NotificationTemplateType, - NotificationTemplateTypeTranslateMap, SlackChanelType + NotificationTemplateTypeTranslateMap, + NotificationType, + SlackChanelType } from '@shared/models/notification.models'; import { Component, Inject, OnDestroy, ViewChild } from '@angular/core'; import { DialogComponent } from '@shared/components/dialog.component'; @@ -27,7 +28,7 @@ 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 { 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'; @@ -36,7 +37,6 @@ 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'; -import { NotificationType } from '@core/notification/notification.models'; export interface TemplateNotificationDialogData { template?: NotificationTemplate; @@ -64,9 +64,10 @@ export class TemplateNotificationDialogComponent dialogTitle = 'notification.edit-notification-template'; saveButtonLabel = 'action.save'; - notificationTypes = Object.keys(NotificationTemplateType) as NotificationType[]; + notificationTypes = Object.keys(NotificationType) as NotificationType[]; notificationDeliveryMethods = Object.keys(NotificationDeliveryMethod) as NotificationDeliveryMethod[]; notificationTemplateTypeTranslateMap = NotificationTemplateTypeTranslateMap; + notificationDeliveryMethodTranslateMap = NotificationDeliveryMethodTranslateMap; selectedIndex = 0; @@ -109,11 +110,11 @@ export class TemplateNotificationDialogComponent this.templateNotificationForm = this.fb.group({ name: ['', Validators.required], - notificationType: [NotificationTemplateType.GENERAL], + notificationType: [NotificationType.GENERAL], configuration: this.fb.group({ notificationSubject: ['', Validators.required], defaultTextTemplate: ['', Validators.required], - deliveryMethodsTemplates: this.fb.group({}) + deliveryMethodsTemplates: this.fb.group({}, {validators: this.atLeastOne()}) }) }); this.notificationDeliveryMethods.forEach(method => { @@ -190,7 +191,7 @@ export class TemplateNotificationDialogComponent if (this.selectedIndex === 3 && this.selectedIndex < this.maxStepperIndex && this.smsTemplateForm.pristine) { return 'action.skip'; } - if (this.selectedIndex >= this.maxStepperIndex) { + if (this.selectedIndex !== 0 && this.selectedIndex >= this.maxStepperIndex) { return 'action.add'; } return 'action.next'; @@ -229,7 +230,7 @@ export class TemplateNotificationDialogComponent } } - allValid(): boolean { + private allValid(): boolean { return !this.addNotificationTemplate.steps.find((item, index) => { if (item.stepControl.invalid) { item.interacted = true; @@ -240,4 +241,15 @@ export class TemplateNotificationDialogComponent } }); } + + 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}; + }; + } } diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-list.component.html index b3fdc9c208..12efddf393 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.html @@ -15,8 +15,9 @@ limitations under the License. --> - - + + {{ labelText | translate }} + close - - {{ 'entity.entity-list-empty' | translate }} + {{ requiredText | translate }} diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-list.component.ts index 5fa43fe053..c1d7d1b157 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.ts @@ -63,6 +63,15 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV @Input() subType: string; + @Input() + labelText: string; + + @Input() + placeholderText = 'entity.entity-list'; + + @Input() + requiredText = 'entity.entity-list-empty'; + private requiredValue: boolean; get required(): boolean { return this.requiredValue; diff --git a/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.html b/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.html index 89be676ce2..9ef4655959 100644 --- a/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.html @@ -32,7 +32,7 @@ #queueAutocomplete="matAutocomplete" [displayWith]="displayQueueFn" > - + {{getDescription(queue)}} diff --git a/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.scss b/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.scss index 45a4ab9808..849cc88ed4 100644 --- a/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.scss +++ b/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.scss @@ -15,7 +15,7 @@ */ ::ng-deep { - .queue-option { + .template-option { .mat-option-text { display: inline; } diff --git a/ui-ngx/src/app/shared/models/notification.models.ts b/ui-ngx/src/app/shared/models/notification.models.ts index 46955af71d..44a648282a 100644 --- a/ui-ngx/src/app/shared/models/notification.models.ts +++ b/ui-ngx/src/app/shared/models/notification.models.ts @@ -29,7 +29,8 @@ export interface Notification { readonly id: NotificationId; readonly requestId: NotificationRequestId; readonly recipientId: UserId; - readonly type: string; + readonly type: NotificationType; + readonly subject: string; readonly text: string; readonly info: NotificationInfo; readonly originatorType: NotificationOriginatorType; @@ -44,7 +45,7 @@ export interface NotificationInfo { export interface NotificationRequest extends Omit, 'label'> { tenantId?: TenantId; - targets: Array; + targets: Array; templateId: NotificationTemplateId; info?: NotificationInfo; deliveryMethods: Array; @@ -55,6 +56,12 @@ export interface NotificationRequest extends Omit; errors: Map>; @@ -122,7 +129,7 @@ interface CustomerUsersNotificationTargetConfig { export interface NotificationTemplate extends Omit, 'label'>{ tenantId: TenantId; - notificationType: NotificationTemplateType; + notificationType: NotificationType; configuration: NotificationTemplateConfig; } @@ -174,11 +181,25 @@ export enum NotificationDeliveryMethod { SLACK = 'SLACK' } +export const NotificationDeliveryMethodTranslateMap = new Map([ + [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 { - PROCESSED = 'PROCESSED', - SCHEDULED = 'SCHEDULED' + PROCESSING = 'PROCESSING', + SCHEDULED = 'SCHEDULED', + SENT = 'SENT' } +export const NotificationRequestStatusTranslateMap = new Map([ + [NotificationRequestStatus.PROCESSING, 'notification.requet-status.processing'], + [NotificationRequestStatus.SCHEDULED, 'notification.requet-status.scheduled'], + [NotificationRequestStatus.SENT, 'notification.requet-status.sent'] +]); + export enum SlackChanelType { DIRECT= 'DIRECT', PUBLIC_CHANNEL = 'PUBLIC_CHANNEL', @@ -197,7 +218,7 @@ export const NotificationTargetConfigTypeTranslateMap = new Map([ - [NotificationTemplateType.GENERAL, +export const NotificationTemplateTypeTranslateMap = new Map([ + [NotificationType.GENERAL, { name: 'notification.template-type.general', hint: 'hint' } ], - [NotificationTemplateType.ALARM, + [NotificationType.ALARM, { name: 'notification.template-type.alarm', hint: 'hint' diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 5f8065c260..cd78329026 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -63,7 +63,8 @@ "print": "Print", "restore": "Restore", "confirm": "Confirm", - "skip": "Skip" + "skip": "Skip", + "send": "Send" }, "aggregation": { "aggregation": "Aggregation", @@ -2672,34 +2673,66 @@ "add-notification-template": "Add notification template", "add-target": "Add target", "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", "create-target": "Create target", "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-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-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", "edit-notification-target": "Edit notification target", "edit-notification-template": "Edit notification template", + "email-settings": "Email settings", "get-customer-id-from-originator": "Get Customer id from originator", "inbox": "Inbox", "management": "Notification management", + "mark-all-read": "Mark all read", + "mark-as-read": "Mark as read", "message": "Message", "message-required": "Message is required", + "new-notification": "New notification", "no-inbox-notification": "No notification found", + "no-notification-request": "No notification request", "no-notification-templates": "No notification templates", "no-targets-notification": "No targets notification", "notification-center": "Notification center", "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", + "search-notification": "Search notification", "search-targets": "Search targets", "search-templates": "Search templates", "send-notification": "Send notification", + "send-test-notification": "Send test notification", "sent": "Sent", "settings": "Notification settings", + "slack-settings": "Slack settings", + "sms-settings": "SMS settings", + "status": "Status", "subject": "Subject", "subject-required": "Subject is required", + "target": "Target", "target-name": "Name", "target-name-required": "Name is required", "target-type": { @@ -2709,20 +2742,18 @@ "user-list": "User list" }, "targets": "Targets", + "targets-required": "Targets is required", "template": "Template", + "template-required": "Template is required", "template-type": { - "general": "General", - "alarm": "Alarm" + "alarm": "Alarm", + "general": "General" }, "templates": "Templates", "text": "Text", "type": "Type", - "basic-settings": "Basic settings", - "send-test-notification": "Send test notification", - "web-settings": "Web settings", - "email-settings": "Email settings", - "sms-settings": "SMS settings", - "slack-settings": "Slack settings" + "unread": "Unread", + "web-settings": "Web settings" }, "ota-update": { "add": "Add package", @@ -3498,7 +3529,9 @@ "enable-account-message": "User account was successfully enabled!", "disable-account-message": "User account was successfully disabled!", "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": { "type": "Value type",