Skip to content
This repository has been archived by the owner on Jun 17, 2022. It is now read-only.

Commit

Permalink
Dynamic Modals (#417)
Browse files Browse the repository at this point in the history
* Move backdrop and click handler to modal service since they should not be used in web

* Add support for opening modals using ViewContainerRef
  • Loading branch information
Hinton committed Aug 26, 2021
1 parent add4b2f commit daa4f6f
Show file tree
Hide file tree
Showing 11 changed files with 313 additions and 113 deletions.
78 changes: 0 additions & 78 deletions angular/src/components/modal.component.ts

This file was deleted.

57 changes: 57 additions & 0 deletions angular/src/components/modal/dynamic-modal.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
AfterViewInit,
ChangeDetectorRef,
Component,
ComponentFactoryResolver,
ComponentRef,
ElementRef,
OnDestroy,
Type,
ViewChild,
ViewContainerRef
} from '@angular/core';

import { ModalRef } from './modal.ref';

@Component({
selector: 'app-modal',
template: '<ng-template #modalContent></ng-template>',
})
export class DynamicModalComponent implements AfterViewInit, OnDestroy {
componentRef: ComponentRef<any>;

@ViewChild('modalContent', { read: ViewContainerRef, static: true }) modalContentRef: ViewContainerRef;

childComponentType: Type<any>;
setComponentParameters: (component: any) => void;

constructor(private componentFactoryResolver: ComponentFactoryResolver, private cd: ChangeDetectorRef,
private el: ElementRef<HTMLElement>, public modalRef: ModalRef) {}

ngAfterViewInit() {
this.loadChildComponent(this.childComponentType);
if (this.setComponentParameters != null) {
this.setComponentParameters(this.componentRef.instance);
}
this.cd.detectChanges();

this.modalRef.created(this.el.nativeElement);
}

loadChildComponent(componentType: Type<any>) {
const componentFactory = this.componentFactoryResolver.resolveComponentFactory(componentType);

this.modalContentRef.clear();
this.componentRef = this.modalContentRef.createComponent(componentFactory);
}

ngOnDestroy() {
if (this.componentRef) {
this.componentRef.destroy();
}
}

close() {
this.modalRef.close();
}
}
15 changes: 15 additions & 0 deletions angular/src/components/modal/modal-injector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {
InjectFlags,
InjectionToken,
Injector,
Type
} from '@angular/core';

export class ModalInjector implements Injector {
constructor(private _parentInjector: Injector, private _additionalTokens: WeakMap<any, any>) {}

get<T>(token: Type<T> | InjectionToken<T>, notFoundValue?: T, flags?: InjectFlags): T;
get(token: any, notFoundValue?: any, flags?: any) {
return this._additionalTokens.get(token) ?? this._parentInjector.get<any>(token, notFoundValue);
}
}
51 changes: 51 additions & 0 deletions angular/src/components/modal/modal.ref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Observable, Subject } from 'rxjs';
import { first } from 'rxjs/operators';

export class ModalRef {

onCreated: Observable<HTMLElement>; // Modal added to the DOM.
onClose: Observable<any>; // Initiated close.
onClosed: Observable<any>; // Modal was closed (Remove element from DOM)
onShow: Observable<any>; // Start showing modal
onShown: Observable<any>; // Modal is fully visible

private readonly _onCreated = new Subject<HTMLElement>();
private readonly _onClose = new Subject<any>();
private readonly _onClosed = new Subject<any>();
private readonly _onShow = new Subject<any>();
private readonly _onShown = new Subject<any>();
private lastResult: any;

constructor() {
this.onCreated = this._onCreated.asObservable();
this.onClose = this._onClose.asObservable();
this.onClosed = this._onClosed.asObservable();
this.onShow = this._onShow.asObservable();
this.onShown = this._onShow.asObservable();
}

show() {
this._onShow.next();
}

shown() {
this._onShown.next();
}

close(result?: any) {
this.lastResult = result;
this._onClose.next(result);
}

closed() {
this._onClosed.next(this.lastResult);
}

created(el: HTMLElement) {
this._onCreated.next(el);
}

onClosedPromise(): Promise<any> {
return this.onClosed.pipe(first()).toPromise();
}
}
30 changes: 30 additions & 0 deletions angular/src/components/password-reprompt.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Directive } from '@angular/core';

import { CryptoService } from 'jslib-common/abstractions/crypto.service';
import { I18nService } from 'jslib-common/abstractions/i18n.service';
import { PlatformUtilsService } from 'jslib-common/abstractions/platformUtils.service';
import { ModalRef } from './modal/modal.ref';

@Directive()
export class PasswordRepromptComponent {

showPassword = false;
masterPassword = '';

constructor(private modalRef: ModalRef, private cryptoService: CryptoService, private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService) {}

togglePassword() {
this.showPassword = !this.showPassword;
}

async submit() {
if (!await this.cryptoService.compareAndUpdateKeyHash(this.masterPassword, null)) {
this.platformUtilsService.showToast('error', this.i18nService.t('errorOccurred'),
this.i18nService.t('invalidMasterPassword'));
return;
}

this.modalRef.close(true);
}
}
132 changes: 132 additions & 0 deletions angular/src/services/modal.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import {
ApplicationRef,
ComponentFactoryResolver,
ComponentRef,
EmbeddedViewRef,
Injectable,
Injector,
Type,
ViewContainerRef
} from '@angular/core';
import { first } from 'rxjs/operators';

import { DynamicModalComponent } from '../components/modal/dynamic-modal.component';
import { ModalInjector } from '../components/modal/modal-injector';
import { ModalRef } from '../components/modal/modal.ref';

export class ModalConfig<D = any> {
data?: D;
allowMultipleModals: boolean = false;
}

@Injectable()
export class ModalService {
protected modalCount = 0;

constructor(private componentFactoryResolver: ComponentFactoryResolver, private applicationRef: ApplicationRef,
private injector: Injector) {}

async openViewRef<T>(componentType: Type<T>, viewContainerRef: ViewContainerRef,
setComponentParameters: (component: T) => void = null): Promise<[ModalRef, T]> {

this.modalCount++;
const [modalRef, modalComponentRef] = this.openInternal(componentType, null, false);
modalComponentRef.instance.setComponentParameters = setComponentParameters;

viewContainerRef.insert(modalComponentRef.hostView);

await modalRef.onCreated.pipe(first()).toPromise();

return [modalRef, modalComponentRef.instance.componentRef.instance];
}

open(componentType: Type<any>, config?: ModalConfig) {
if (!(config?.allowMultipleModals ?? false) && this.modalCount > 0) {
return;
}
this.modalCount++;

const [modalRef, _] = this.openInternal(componentType, config, true);

return modalRef;
}

protected openInternal(componentType: Type<any>, config?: ModalConfig, attachToDom?: boolean):
[ModalRef, ComponentRef<DynamicModalComponent>] {

const [modalRef, componentRef] = this.createModalComponent(config);
componentRef.instance.childComponentType = componentType;

if (attachToDom) {
this.applicationRef.attachView(componentRef.hostView);
const domElem = (componentRef.hostView as EmbeddedViewRef<any>).rootNodes[0] as HTMLElement;
document.body.appendChild(domElem);
}

modalRef.onClosed.pipe(first()).subscribe(() => {
if (attachToDom) {
this.applicationRef.detachView(componentRef.hostView);
}
componentRef.destroy();
this.modalCount--;
});

this.setupHandlers(modalRef);

return [modalRef, componentRef];
}

protected setupHandlers(modalRef: ModalRef) {
let backdrop: HTMLElement = null;

// Add backdrop, setup [data-dismiss] handler.
modalRef.onCreated.pipe(first()).subscribe(el => {
document.body.classList.add('modal-open');

backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop fade';
backdrop.style.zIndex = `${this.modalCount}040`;
document.body.appendChild(backdrop);

el.querySelector('.modal-dialog').addEventListener('click', (e: Event) => {
e.stopPropagation();
});

const modalEl: HTMLElement = el.querySelector('.modal');
modalEl.style.zIndex = `${this.modalCount}050`;

const modals = Array.from(el.querySelectorAll('.modal, .modal *[data-dismiss="modal"]'));
for (const closeElement of modals) {
closeElement.addEventListener('click', event => {
modalRef.close();
});
}
});

// onClose is used in Web to hook into bootstrap. On other projects we pipe it directly to closed.
modalRef.onClose.pipe(first()).subscribe(() => {
modalRef.closed();

if (this.modalCount === 0) {
document.body.classList.remove('modal-open');
}

if (backdrop != null) {
document.body.removeChild(backdrop);
}
});
}

protected createModalComponent(config: ModalConfig): [ModalRef, ComponentRef<DynamicModalComponent>] {
const modalRef = new ModalRef();

const map = new WeakMap();
map.set(ModalConfig, config);
map.set(ModalRef, modalRef);

const componentFactory = this.componentFactoryResolver.resolveComponentFactory(DynamicModalComponent);
const componentRef = componentFactory.create(new ModalInjector(this.injector, map));

return [modalRef, componentRef];
}
}
28 changes: 28 additions & 0 deletions angular/src/services/passwordReprompt.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Injectable } from '@angular/core';

import { PasswordRepromptService as PasswordRepromptServiceAbstraction } from 'jslib-common/abstractions/passwordReprompt.service';

import { PasswordRepromptComponent } from '../components/password-reprompt.component';
import { ModalService } from './modal.service';

@Injectable()
export class PasswordRepromptService implements PasswordRepromptServiceAbstraction {
protected component = PasswordRepromptComponent;

constructor(private modalService: ModalService) { }

protectedFields() {
return ['TOTP', 'Password', 'H_Field', 'Card Number', 'Security Code'];
}

async showPasswordPrompt() {
const ref = this.modalService.open(this.component, {allowMultipleModals: true});

if (ref == null) {
return false;
}

const result = await ref.onClosedPromise();
return result === true;
}
}
Loading

0 comments on commit daa4f6f

Please sign in to comment.