thingsboard/ui-ngx/src/app/shared/components/toast.directive.ts

377 lines
13 KiB
TypeScript

///
/// Copyright © 2016-2021 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 {
AfterViewInit, ChangeDetectorRef,
Component, ComponentFactoryResolver, ComponentRef,
Directive,
ElementRef, HostBinding,
Inject,
Injector,
Input,
NgZone,
OnDestroy, Optional,
StaticProvider,
ViewChild,
ViewContainerRef
} from '@angular/core';
import { MAT_SNACK_BAR_DATA, MatSnackBar, MatSnackBarConfig, MatSnackBarRef } from '@angular/material/snack-bar';
import { NotificationMessage } from '@app/core/notification/notification.models';
import { Subscription } from 'rxjs';
import { NotificationService } from '@app/core/services/notification.service';
import { BreakpointObserver } from '@angular/cdk/layout';
import { MediaBreakpoints } from '@shared/models/constants';
import { MatButton } from '@angular/material/button';
import Timeout = NodeJS.Timeout;
@Directive({
selector: '[tb-toast]'
})
export class ToastDirective implements AfterViewInit, OnDestroy {
@Input()
toastTarget = 'root';
private notificationSubscription: Subscription = null;
private hideNotificationSubscription: Subscription = null;
private snackBarRef: MatSnackBarRef<any> = null;
private toastComponentRef: ComponentRef<TbSnackBarComponent>;
private currentMessage: NotificationMessage = null;
private dismissTimeout: Timeout = null;
constructor(private elementRef: ElementRef,
private viewContainerRef: ViewContainerRef,
private notificationService: NotificationService,
private componentFactoryResolver: ComponentFactoryResolver,
private snackBar: MatSnackBar,
private ngZone: NgZone,
private breakpointObserver: BreakpointObserver,
private cd: ChangeDetectorRef) {
}
ngAfterViewInit(): void {
this.notificationSubscription = this.notificationService.getNotification().subscribe(
(notificationMessage) => {
if (this.shouldDisplayMessage(notificationMessage)) {
this.currentMessage = notificationMessage;
const isGtSm = this.breakpointObserver.isMatched(MediaBreakpoints['gt-sm']);
if (isGtSm && this.toastTarget !== 'root') {
this.showToastPanel(notificationMessage);
} else {
this.showSnackBar(notificationMessage, isGtSm);
}
}
}
);
this.hideNotificationSubscription = this.notificationService.getHideNotification().subscribe(
(hideNotification) => {
if (hideNotification) {
const target = hideNotification.target || 'root';
if (this.toastTarget === target) {
this.ngZone.run(() => {
if (this.snackBarRef) {
this.snackBarRef.dismiss();
}
if (this.toastComponentRef) {
this.toastComponentRef.instance.actionButton._elementRef.nativeElement.click();
}
});
}
}
}
);
}
private showToastPanel(notificationMessage: NotificationMessage) {
this.ngZone.run(() => {
if (this.snackBarRef) {
this.snackBarRef.dismiss();
}
if (this.toastComponentRef) {
this.viewContainerRef.detach(0);
this.toastComponentRef.destroy();
}
let panelClass = ['tb-toast-panel', 'toast-panel'];
if (notificationMessage.panelClass) {
if (typeof notificationMessage.panelClass === 'string') {
panelClass.push(notificationMessage.panelClass);
} else if (notificationMessage.panelClass.length) {
panelClass = panelClass.concat(notificationMessage.panelClass);
}
}
const horizontalPosition = notificationMessage.horizontalPosition || 'left';
const verticalPosition = notificationMessage.verticalPosition || 'top';
if (horizontalPosition === 'start' || horizontalPosition === 'left') {
panelClass.push('left');
} else if (horizontalPosition === 'end' || horizontalPosition === 'right') {
panelClass.push('right');
} else {
panelClass.push('h-center');
}
if (verticalPosition === 'top') {
panelClass.push('top');
} else {
panelClass.push('bottom');
}
const componentFactory = this.componentFactoryResolver.resolveComponentFactory(TbSnackBarComponent);
const data: ToastPanelData = {
notification: notificationMessage,
panelClass,
destroyToastComponent: () => {
this.viewContainerRef.detach(0);
this.toastComponentRef.destroy();
}
};
const providers: StaticProvider[] = [
{provide: MAT_SNACK_BAR_DATA, useValue: data}
];
const injector = Injector.create({parent: this.viewContainerRef.injector, providers});
this.toastComponentRef = this.viewContainerRef.createComponent(componentFactory, 0, injector);
this.cd.detectChanges();
if (notificationMessage.duration && notificationMessage.duration > 0) {
if (this.dismissTimeout !== null) {
clearTimeout(this.dismissTimeout);
this.dismissTimeout = null;
}
this.dismissTimeout = setTimeout(() => {
if (this.toastComponentRef) {
this.toastComponentRef.instance.actionButton._elementRef.nativeElement.click();
}
this.dismissTimeout = null;
}, notificationMessage.duration + 500);
}
this.toastComponentRef.onDestroy(() => {
if (this.dismissTimeout !== null) {
clearTimeout(this.dismissTimeout);
this.dismissTimeout = null;
}
this.toastComponentRef = null;
this.currentMessage = null;
});
});
}
private showSnackBar(notificationMessage: NotificationMessage, isGtSm: boolean) {
this.ngZone.run(() => {
if (this.snackBarRef) {
this.snackBarRef.dismiss();
}
const data: ToastPanelData = {
notification: notificationMessage,
parent: this.elementRef,
panelClass: [],
destroyToastComponent: () => {}
};
const config: MatSnackBarConfig = {
horizontalPosition: notificationMessage.horizontalPosition || 'left',
verticalPosition: !isGtSm ? 'bottom' : (notificationMessage.verticalPosition || 'top'),
viewContainerRef: this.viewContainerRef,
duration: notificationMessage.duration,
panelClass: notificationMessage.panelClass,
data
};
this.snackBarRef = this.snackBar.openFromComponent(TbSnackBarComponent, config);
if (notificationMessage.duration && notificationMessage.duration > 0 && notificationMessage.forceDismiss) {
if (this.dismissTimeout !== null) {
clearTimeout(this.dismissTimeout);
this.dismissTimeout = null;
}
this.dismissTimeout = setTimeout(() => {
if (this.snackBarRef) {
this.snackBarRef.instance.actionButton._elementRef.nativeElement.click();
}
this.dismissTimeout = null;
}, notificationMessage.duration);
}
this.snackBarRef.afterDismissed().subscribe(() => {
if (this.dismissTimeout !== null) {
clearTimeout(this.dismissTimeout);
this.dismissTimeout = null;
}
this.snackBarRef = null;
this.currentMessage = null;
});
});
}
private shouldDisplayMessage(notificationMessage: NotificationMessage): boolean {
if (notificationMessage && notificationMessage.message) {
const target = notificationMessage.target || 'root';
if (this.toastTarget === target) {
if (!this.currentMessage || this.currentMessage.message !== notificationMessage.message
|| this.currentMessage.type !== notificationMessage.type) {
return true;
}
}
}
return false;
}
ngOnDestroy(): void {
if (this.toastComponentRef) {
this.viewContainerRef.detach(0);
this.toastComponentRef.destroy();
}
if (this.notificationSubscription) {
this.notificationSubscription.unsubscribe();
}
if (this.hideNotificationSubscription) {
this.hideNotificationSubscription.unsubscribe();
}
}
}
interface ToastPanelData {
notification: NotificationMessage;
parent?: ElementRef;
panelClass: string[];
destroyToastComponent: () => void;
}
import {
AnimationTriggerMetadata,
AnimationEvent,
trigger,
state,
transition,
style,
animate,
} from '@angular/animations';
import { onParentScrollOrWindowResize } from '@core/utils';
export const toastAnimations: {
readonly showHideToast: AnimationTriggerMetadata;
} = {
showHideToast: trigger('showHideAnimation', [
state('in', style({ transform: 'scale(1)', opacity: 1 })),
transition('void => opened', [style({ transform: 'scale(0)', opacity: 0 }), animate('{{ open }}ms')]),
transition(
'opened => closing',
animate('{{ close }}ms', style({ transform: 'scale(0)', opacity: 0 })),
),
]),
};
export type ToastAnimationState = 'default' | 'opened' | 'closing';
@Component({
selector: 'tb-snack-bar-component',
templateUrl: 'snack-bar-component.html',
styleUrls: ['snack-bar-component.scss'],
animations: [toastAnimations.showHideToast]
})
export class TbSnackBarComponent implements AfterViewInit, OnDestroy {
@ViewChild('actionButton', {static: true}) actionButton: MatButton;
@HostBinding('class')
get panelClass(): string[] {
return this.data.panelClass;
}
private parentEl: HTMLElement;
private snackBarContainerEl: HTMLElement;
private parentScrollSubscription: Subscription = null;
public notification: NotificationMessage;
animationState: ToastAnimationState;
animationParams = {
open: 100,
close: 100
};
constructor(@Inject(MAT_SNACK_BAR_DATA)
private data: ToastPanelData,
private elementRef: ElementRef,
@Optional()
private snackBarRef: MatSnackBarRef<TbSnackBarComponent>) {
this.animationState = !!this.snackBarRef ? 'default' : 'opened';
this.notification = data.notification;
}
ngAfterViewInit() {
if (this.snackBarRef) {
this.parentEl = this.data.parent.nativeElement;
this.snackBarContainerEl = $(this.elementRef.nativeElement).closest('snack-bar-container')[0];
this.snackBarContainerEl.style.position = 'absolute';
this.updateContainerRect();
this.updatePosition(this.snackBarRef.containerInstance.snackBarConfig);
this.parentScrollSubscription = onParentScrollOrWindowResize(this.parentEl).subscribe(() => {
this.updateContainerRect();
});
}
}
private updatePosition(config: MatSnackBarConfig) {
const isRtl = config.direction === 'rtl';
const isLeft = (config.horizontalPosition === 'left' ||
(config.horizontalPosition === 'start' && !isRtl) ||
(config.horizontalPosition === 'end' && isRtl));
const isRight = !isLeft && config.horizontalPosition !== 'center';
if (isLeft) {
this.snackBarContainerEl.style.justifyContent = 'flex-start';
} else if (isRight) {
this.snackBarContainerEl.style.justifyContent = 'flex-end';
} else {
this.snackBarContainerEl.style.justifyContent = 'center';
}
if (config.verticalPosition === 'top') {
this.snackBarContainerEl.style.alignItems = 'flex-start';
} else {
this.snackBarContainerEl.style.alignItems = 'flex-end';
}
}
private updateContainerRect() {
const viewportOffset = this.parentEl.getBoundingClientRect();
this.snackBarContainerEl.style.top = viewportOffset.top + 'px';
this.snackBarContainerEl.style.left = viewportOffset.left + 'px';
this.snackBarContainerEl.style.width = viewportOffset.width + 'px';
this.snackBarContainerEl.style.height = viewportOffset.height + 'px';
}
ngOnDestroy() {
if (this.parentScrollSubscription) {
this.parentScrollSubscription.unsubscribe();
}
}
action(event: MouseEvent): void {
event.stopPropagation();
if (this.snackBarRef) {
this.snackBarRef.dismissWithAction();
} else {
this.animationState = 'closing';
}
}
onHideFinished(event: AnimationEvent) {
const { toState } = event;
const isFadeOut = (toState as ToastAnimationState) === 'closing';
const itFinished = this.animationState === 'closing';
if (isFadeOut && itFinished) {
this.data.destroyToastComponent();
}
}
}