Модальные окна и нотификация в Angular

в 10:48, , рубрики: angular, AngularJS, modal dialog, TypeScript

В Angular (версии 2+) столкнулся с задачей создания модальных окон, но готовые решения мне не подходили либо из-за жестко заданного функционала (негибкие), либо они не обновлены до последней версии и по этой причине не работают. Но продираясь через дебри официальной документации решил рассказать о двух способах работы с модальными окнами (или нотификаций), которые я посчитал лучшими.

В данной статье я хочу рассказать о двух способах работы с модальными окнами:

  1. «Обычное» добавление компонентов
  2. Динамическое добавление компонентов


В одной из статей на Хабре дается неплохой, на мой взгляд, способ решения данной проблемы, но он перестал работать после введения NgModule (а может и раньше). С данной статьей материал будет пересекаться, поэтому советую ознакомится и с ней.

Сразу скажу что есть еще несколько способов добавления модальных окон, такие как добавление в стиле bootstrap (похоже на 1 способ, только в 1 способе модальное окно вынесли в отдельный компонент), так же никто не мешает использовать typescript чтоб напрямую в dom добавить какое-либо модальное окно, хоть этот способ мне и не нравится, но он существует.

Во всех примерах я буду опускать css и html в тех местах, где это не будет влиять на логику. Ссылка на репозиторий с исходным кодом будет приведена в конце статьи.

«Обычное» добавление компонентов.

Создадим, для начала, компонент, который будет простым диалоговым окном для подтверждения:

@Component({
	           selector     : 'modal-dialog',
...
           })

export class ModalDialogComponent {
	@Input() header: string;
	@Input() description: string;
	@Output() isConfirmed: EventEmitter<boolean> = new EventEmitter<boolean>();
	private confirm() {
		this.isConfirmed.emit(true);
	}
	private close() {
		this.isConfirmed.emit(false);
	}
}

Мы создали компонент с входными значениями header и description и в ответ получаем одно значение boolean переменной с результатом работы окна. Если будет необходимость вернуть какие-либо значения с модального окна в компонент, который его вызвал, можно создать класс для представления результата выполнения:

export class ModalDialogResult {
	public isConfirmed: boolean;
        public data:any;
}

И возвращать данные через него.

Теперь для использованием диалогового окна нам необходимо добавить его в какой-либо модуль. Тут есть несколько способов:

  1. Объединить модальные окна в один модуль
  2. Добавить в тот модуль, где он будет использоваться

Для данного способа создания модального окна я выбрал добавить его в тот модуль, где он будет использоваться:

@NgModule({
	          imports     : [BrowserModule],
	          declarations: [SimpleModalPageComponent, ModalDialogComponent],
	          bootstrap   : [SimpleModalPageComponent]
          })

export class SimpleModalPageModule {
}

ModalDialogComponent — это собственно сам компонент диалогового окна.
SimpleModalPageComponent — это компонент (далее компоненты, которые имеют в названии слово Page буду называть страницы), где будем отображать диалоговое окно.

Теперь добавим модальное окно в template страницы:

<div class="container">
	<div class="configuration">
		<div>
			<label for="header">Заголовок:</label>
			<input type="text" id="header" #header class="simple-input">
		</div>
		<label for="description">Содержимое:</label>
		<input type="text" id="description" #description content="description" class="simple-input">
	</div>
	<div>
		<button class="simple-button" (click)="showDialog()">Show Dialog</button>
	</div>
</div>
<modal-dialog *ngIf="isModalDialogVisible" [header]="header.value" [description]="description.value"        (isConfirmed)="closeModal($event)"></modal-dialog>

Управлять видимостью модального окна будем через ngIf. При желании эту логику можно перенести внутрь диалогового окна, либо объединить кнопку для отображения окна с самим окном в один компонент.

Код страницы для отображения диалогового окна:

....
export class SimpleModalPageComponent {
	private isModalDialogVisible: boolean = false;
	public showDialog() {
		this.isModalDialogVisible = true;
	}

	public closeModal(isConfirmed: boolean) {
		this.isModalDialogVisible = false;
		...
	}
}

Диалоговое окно готово для использования. Для работы с всплывающей нотификацией (Toast, popup и т.п) работа будет происходить несколько иначе. Для работы с нотификицией нужен стек (если необходимо более чем одно всплывающее сообщение на экране), который должен быть общим для всего приложения. Далее рассмотрим как это можно сделать.

Для начала давайте создадим сервис, который будет отвечать за доступ к нотификации и модель нотификации:

@Injectable()
export class TinyNotificationService {
	private notifications: Subject<TinyNotificationModel> = new Subject<TinyNotificationModel>();

	public getNotifications(): Subject<TinyNotificationModel> {
		return this.notifications;
	}

	public showToast(info: TinyNotificationModel) {
		this.notifications.next(info);
	}
}

export class TinyNotificationModel {
	public header: string;
	public description: string;

	constructor(header: string, description: string) {
		this.header      = header;
		this.description = description;
	}
}

В модели мы определяем заголовок и описание. В сервисе мы определили метод для отображения нотификации и метод для получения моделей нотификации.

Теперь определим компонент нотификации:

@Component({
	           selector     : "notifications",
	           template     : `
<div class="tiny-notification-panel">
	<div *ngFor="let notification of notifications" class="tiny-notification">
		<div class="header-block">
			<h3 class="header-title">{{notification.header}}</h3>
			<a class="close-button" (click)="closeNotification(notification)">x</a>
		</div>
		<div class="content">
			<span>{{notification.description}}</span>
		</div>
	</div>
</div>`
           })

export class TinyNotificationComponent {
	notifications: Set<TinyNotificationModel> = new Set<TinyNotificationModel>();

	constructor(private _notificationService: TinyNotificationService) {
		this._notificationService.getNotification()
		    .subscribe((notification: TinyNotificationModel)=> {
			    this.notifications.add(notification);
			    setTimeout(()=> {
				    this.closeNotification(notification);
			    }, 5000);
		    });
	}

	public closeNotification(notification: TinyNotificationModel) {
		this.notifications.delete(notification);
	}
}

В конструкторе мы подписываемся на добавление нотификации и задаем автозакрытие нотификации по истечении 5 секунд.

Для использования такой нотификации необходимо добавить компонент нотификации, желательно, как можно выше в иерархии компонентов (в главный компонент).

Для использования добавляем в template страницы (SimpleModalPageComponent)

<notifications></notifications>

После этого можно будет вызывать нотификацию через сервис, к примеру, следующим способом

...
	constructor(private notificationService: TinyNotificationService) {}

	public showToast(header: string, description: string) {
		this.notificationService.showToast(new TinyNotificationModel(header, description));
	}
...

Не забываем добавлять компоненты и сервисы в модули.

Динамическое добавление компонентов

Думаю нужно сразу сказать почему я решил не создавать очередной модный и молодеждый пакет в npm и просто описать подход для создания модальных окон. Причина в том, что создать универсальный пакет сложно и все равно он будет подходить малому количеству пользователей (вспоминается история про то, что усредненное и универсальное решение рискует не подойти никому).

А теперь перейдем к тому из-за чего я затеял написание этой статьи. Добавить компонент динамически «из воздуха» в Angular не получится (скорее всего можно, но это сложно и рискует часто ломаться с обновлениями). Поэтому все должно быть где-то определено явно (на мой взгляд это хорошо).

Для динамического добавления компонентов должно быть известно куда мы их планируем добавлять. Для этого нам необходимо получить объект ViewContainerRef.

Получить его можно следующим способом:

@Component({
...
	           template: `
...
<section #notificationBlock></section>
`,
...
           })
export class DynamicModalPageComponent implements OnInit {
	@ViewChild('notificationBlock', { read: ViewContainerRef }) notificationBlock: ViewContainerRef;
	constructor(private notificationManager: NotificationManager) { }

	public ngOnInit(): void {
		this.notificationManager.init(this.notificationBlock);
	}
..
}

Таким образом мы получаем объект ViewContainerRef. Как вы могли заметить помимо этого объекта мы используем NotificationManager и инициализируем его значением ViewContainerRef.

NotificationManager предназначен для работы с модальными окнами и нотификацией. Далее мы определим данный класс:

@Injectable()
export class NotificationManager {
	private notificationBlock: ViewContainerRef;
...
	constructor(private componentFactoryResolver: ComponentFactoryResolver) { }

	public init(notificationBlock: ViewContainerRef) {
		this.notificationBlock = notificationBlock;
...
	}
...

	private createComponent<T>(componentType: {new (...args: any[]): T;}): ComponentRef<T> {
		const injector = ReflectiveInjector.fromResolvedProviders([], this.notificationBlock.parentInjector);
		const factory  = this.componentFactoryResolver.resolveComponentFactory(componentType);
		return factory.create(injector);
	}

	private createNotificationWithData<T>(componentType: {new (...args: any[]): T;}, data: any): ComponentRef<T> {
		const component = this.createComponent(componentType);
		Object.assign(component.instance, data);

		return component;
	}
}

В предыдущем листинге я намеренно пропустил некоторые части кода, чтоб ввести их после некоторых объяснений. До того как добавлять компонент куда-либо нам необходимо его вначале создать. Методы createComponent и createNotificationWithData это внутренние методы класса и предназначены для создания компонента и инициализации его какими-либо данными соответственно.

Рассмотрим метод createComponent:

private createComponent<T>(componentType: {new (...args: any[]): T;}): ComponentRef<T> {
		const injector = ReflectiveInjector.fromResolvedProviders([], this.notificationBlock.parentInjector);
		const factory  = this.componentFactoryResolver.resolveComponentFactory(componentType);
		return factory.create(injector);
	}

На вход мы подаем класс компонента, далее используем метод fromResolvedProviders из ReflectiveInjector для получения объекта ReflectiveInjector. Далее через ComponentFactoryResolver создаем фабрику для компонента и собственно создаем компонент.

Метод createNotificationWithData создает компонент и добавляет к нему данные:

private createNotificationWithData<T>(componentType: {new (...args: any[]): T;}, data: any): ComponentRef<T> {
		const component = this.createComponent(componentType);
		Object.assign(component.instance, data);

		return component;
	}

После того, как мы разобрали методы для создания компонентов необходимо рассмотреть как эти объекты использовать. Добавим в NotificationManager метод для отображения модального окна:

@Injectable()
export class NotificationManager {
...
	public showDialog<T extends ModalDialogBase>(componentType: {new (...args: any[]): T;},
	                                             header: string,
	                                             description: string): Subject<ModalDialogResult> {
		const dialog = this.createNotificationWithData(componentType, {
			header     : header,
			description: description
		});
		this.notificationBlock.insert(dialog.hostView);
		const subject = dialog.instance.getDialogState();
		const sub     = subject.subscribe(x=> {
			dialog.destroy();
			sub.unsubscribe();
		});
		return subject;
	}
...
}

ModalDialogBase это базовый класс для модели. Спрячу его под спойлер вместе с ModalDialogResult

ModalDialogBase and ModalDialogResult

export abstract class ModalDialogBase {
	public abstract getDialogState(): Subject<ModalDialogResult>;
}

export enum ModalDialogResult{
	Opened,
	Confirmed,
	Closed
}

Метод showDialog принимает класс компонента, данные для его инициализации и возвращает Subject для получения результата выполнения модального окна.

Для добавления компонента используем метод insert у notificationBlock

this.notificationBlock.insert(dialog.hostView);

Данный метод добавляет компонент и после этого он будет отображен пользователю. Через dialog.instance мы получаем объект компонента и можем обращается к его методам и полям. Например мы можем подписаться на получение результата и удалить данное диалоговое окно из dom после закрытия:

const subject = dialog.instance.getDialogState();
const sub     = subject.subscribe(x=> {
			dialog.destroy();
			sub.unsubscribe();
		});

Если вызвать у объекта ComponentRef метод destroy компонент удалится не только из dom, но и из notificationBlock, что очень удобно.

Под спойлером код модального окна:

ModalDialog

@Component({
	           selector     : 'modal-dialog',
	           template     : `
<div class="modal-background">
	<div class="container">
		<div class="header-block">
			<h3 class="header-title">{{header}}</h3>
			<a class="close-button" (click)="close()">x</a>
		</div>
		<div class="content">
			<span>{{description}}</span>
		</div>
		<div class="action-block">
			<button class="simple-button" (click)="confirm()">ОК</button>
			<button class="simple-button" (click)="close()">Отмена</button>
		</div>
	</div>
</div>
`
           })

export class ModalDialogComponent extends ModalDialogBase {
	private header: string;
	private description: string;
	private modalState: Subject<ModalDialogResult>;
	constructor() {
		super();
		this.modalState = new Subject();
	}
	public getDialogState(): Subject<ModalDialogResult> {
		return this.modalState;
	}
	private confirm() {
		this.modalState.next(ModalDialogResult.Confirmed);
	}
	private close() {
		this.modalState.next(ModalDialogResult.Closed);
	}
}

Далее давайте рассмотрим создание нотификации. Мы может добавлять ее так же, как и модальные окна, но на мой взгляд их лучше выделить в отдельное место, поэтому давайте создадим компонент NotificationPanelComponent:

@Component({
	           selector     : 'notification-panel',
	           template     : `
<div class="notification-panel">
	<div #notifications></div>
</div>
           })
export class NotificationPanelComponent {
	@ViewChild('notifications', { read: ViewContainerRef }) notificationBlock: ViewContainerRef;

	public showNotification<T extends NotificationBase>(componentRef: ComponentRef<T>, timeOut: number) {
		const toast = componentRef;
		this.notificationBlock.insert(toast.hostView);
		let subscription = toast.instance.getClosedEvent()
		                        .subscribe(()=> {
			                        this.destroyComponent(toast, subscription);
		                        });
		setTimeout(()=> {
			toast.instance.close();
		}, timeOut);
	}

	private destroyComponent<T extends NotificationBase>(componentRef: ComponentRef<T>, subscription: Subscription) {
		componentRef.destroy();
		subscription.unsubscribe();
	}
}

В методе showNotification мы добавляем компонент для отображения, подписываемся на событие закрытия окна и задаем таймаут для закрытия окна. Для простоты закрытие реализовано через метод close у компонента нотификации.

Все нотификации должны наследоваться от класса NotificationBase.

NotificationBase

export abstract class NotificationBase {
	protected closedEvent = new Subject();

	public getClosedEvent(){
		return this.closedEvent;
	}
	public abstract close(): void;
}

И приведем код самого компонента нотификации:

@Component({
	           selector     : 'tiny-notification',
	           template     : `
<div class="container">
	<div class="header-block">
		<h3 class="header-title">{{header}}</h3>
		<a class="close-button" (click)="close()">x</a>
	</div>
	<div class="content">
		<span>{{description}}</span>
	</div>
</div>`
           })
export class TinyNotificationComponent extends NotificationBase {
	public header: string;
	public description: string;

	close() {
		this.closedEvent.next();
		this.closedEvent.complete();
	}
}

Для использования нотификации необходимо добавить метод showToast и NotificationPanelComponent в NotificationManager:

@Injectable()
export class NotificationManager {
	private notificationBlock: ViewContainerRef;
	private notificationPanel: NotificationPanelComponent;

	constructor(private componentFactoryResolver: ComponentFactoryResolver) { }

	public init(notificationBlock: ViewContainerRef) {
		this.notificationBlock = notificationBlock;
		const component          = this.createComponent(NotificationPanelComponent);
		this.notificationPanel = component.instance;
		this.notificationBlock.insert(component.hostView);
	}
...
	public showToast(header: string, description: string, timeOut: number = 3000) {
		const component = this.createNotificationWithData<TinyNotificationComponent>(TinyNotificationComponent, {
			header     : header,
			description: description
		});
		this.notificationPanel.showNotification(component, timeOut);
	}
...

Если попробовать сделать все, что было приведено до этого, то ничего работать не будет, ведь есть нюанс, а именно в том, как все это объединять в модули. Например если вы попробуете найти информацию где-либо, кроме оф. документации по NgModule, то рискуете не увидеть информацию про такую вещь, как entryComponents.

В оф. документации написано:

entryComponents : Array<Type<any>|any[]>
Specifies a list of components that should be compiled when this module is defined. For each component listed here, Angular will create a ComponentFactory and store it in the ComponentFactoryResolver.

То есть если мы хотим создавать компоненты через ComponentFactory и ComponentFactoryResolver нам необходимо указывать наши компоненты помимо declarations еще и в entryComponents.

Пример модуля:

@NgModule({
 declarations   : [TinyNotificationComponent, NotificationPanelComponent, ModalDialogComponent],
entryComponents: [TinyNotificationComponent, NotificationPanelComponent, ModalDialogComponent],
providers      : [NotificationManager]
          })
export class NotificationModule {
}

По поводу объединения в модули. Я считаю неплохим вариантов объединять схожий функционал модальных окон в модули и импортировать их в NotificationModule.

Теперь для использования модальных окон необходимо только указать NotificationModule в imports и можно использовать.

Пример использования:

...
export class DynamicModalPageComponent implements OnInit {
	....
	constructor(private notificationManager: NotificationManager) { }

	public ngOnInit(): void {
		this.notificationManager.init(this.notificationBlock);
	}

	public showToast(header: string, description: string) {
		this.notificationManager.showToast(header, description, 3000);
	}

	public showDialog(header: string, description: string) {
		this.notificationManager.showDialog(ModalDialogComponent, header, description)
		    .subscribe((x: ModalDialogResult)=> {
			    if (x == ModalDialogResult.Confirmed) {
				    this.showToast(header, "modal dialog is confirmed");
			    }
			    else {
				    this.showToast(header, "modal dialog is closed");
			    }
		    });
	}
}

В данной статье мы рассмотрели способы создания модальных окон, в том числе динамически.

→ Исходный код по статье находится в данном репозитории.

Автор: boikovdmitriy

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js