Browse Source

feat(admin-ui): Create ModalService and supporting components

Michael Bromley 7 years ago
parent
commit
6c7b78189a

+ 2 - 0
admin-ui/src/app/core/core.module.ts

@@ -1,4 +1,5 @@
 import { NgModule } from '@angular/core';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
 import { DataModule } from '../data/data.module';
 import { SharedModule } from '../shared/shared.module';
 import { AppShellComponent } from './components/app-shell/app-shell.component';
@@ -19,6 +20,7 @@ import { OverlayHostService } from './providers/overlay-host/overlay-host.servic
     imports: [
         DataModule,
         SharedModule,
+        BrowserAnimationsModule,
     ],
     exports: [
         SharedModule,

+ 14 - 0
admin-ui/src/app/shared/components/modal-dialog/dialog-buttons.directive.ts

@@ -0,0 +1,14 @@
+import { Directive, TemplateRef } from '@angular/core';
+import { ModalDialogComponent } from './modal-dialog.component';
+
+/**
+ * A helper directive used to correctly embed the modal buttons in the {@link ModalDialogComponent}.
+ */
+@Directive({selector: '[vdrDialogButtons]'})
+export class DialogButtonsDirective {
+    constructor(private modal: ModalDialogComponent<any>, private templateRef: TemplateRef<any>) {}
+
+    ngOnInit() {
+        this.modal.registerButtonsTemplate(this.templateRef);
+    }
+}

+ 21 - 0
admin-ui/src/app/shared/components/modal-dialog/dialog-component-outlet.component.ts

@@ -0,0 +1,21 @@
+import { Component, ComponentFactoryResolver, EventEmitter, Input, OnInit, Output, Type, ViewContainerRef } from '@angular/core';
+
+/**
+ * A helper component used to embed a component instance into the {@link ModalDialogComponent}
+ */
+@Component({
+    selector: 'vdr-dialog-component-outlet',
+    template: ``,
+})
+export class DialogComponentOutletComponent implements OnInit {
+    @Input() component: Type<any>;
+    @Output() create = new EventEmitter<any>();
+
+    constructor(private viewContainerRef: ViewContainerRef, private componentFactoryResolver: ComponentFactoryResolver) {}
+
+    ngOnInit() {
+        const factory = this.componentFactoryResolver.resolveComponentFactory(this.component);
+        const componentRef = this.viewContainerRef.createComponent(factory);
+        this.create.emit(componentRef.instance);
+    }
+}

+ 14 - 0
admin-ui/src/app/shared/components/modal-dialog/dialog-title.directive.ts

@@ -0,0 +1,14 @@
+import { Directive, TemplateRef } from '@angular/core';
+import { ModalDialogComponent } from './modal-dialog.component';
+
+/**
+ * A helper directive used to correctly embed the modal title in the {@link ModalDialogComponent}.
+ */
+@Directive({selector: '[vdrDialogTitle]'})
+export class DialogTitleDirective {
+    constructor(private modal: ModalDialogComponent<any>, private templateRef: TemplateRef<any>) {}
+
+    ngOnInit() {
+        this.modal.registerTitleTemplate(this.templateRef);
+    }
+}

+ 15 - 0
admin-ui/src/app/shared/components/modal-dialog/modal-dialog.component.html

@@ -0,0 +1,15 @@
+<clr-modal [clrModalOpen]="true"
+           (clrModalOpenChange)="modalOpenChange($event)"
+           [clrModalClosable]="options.closable"
+           [clrModalSize]="options.size">
+    <h3 class="modal-title">
+        <ng-container *ngTemplateOutlet="titleTemplateRef$ | async"></ng-container>
+    </h3>
+    <div class="modal-body">
+        <vdr-dialog-component-outlet [component]="childComponentType"
+                                     (create)="onCreate($event)"></vdr-dialog-component-outlet>
+    </div>
+    <div class="modal-footer">
+        <ng-container *ngTemplateOutlet="buttonsTemplateRef$ | async"></ng-container>
+    </div>
+</clr-modal>

+ 0 - 0
admin-ui/src/app/shared/components/modal-dialog/modal-dialog.component.scss


+ 62 - 0
admin-ui/src/app/shared/components/modal-dialog/modal-dialog.component.ts

@@ -0,0 +1,62 @@
+import { Component, ContentChild, ContentChildren, QueryList, TemplateRef, Type, ViewChild, ViewChildren } from '@angular/core';
+import { Observable, Subject } from 'rxjs';
+import { Dialog, ModalOptions } from '../../providers/modal/modal.service';
+import { DialogButtonsDirective } from './dialog-buttons.directive';
+
+/**
+ * This component should only be instatiated dynamically by the ModalService. It should not be used
+ * directly in templates. See {@link ModalService.fromComponent} method for more detail.
+ */
+@Component({
+  selector: 'vdr-modal-dialog',
+  templateUrl: './modal-dialog.component.html',
+  styleUrls: ['./modal-dialog.component.scss'],
+})
+export class ModalDialogComponent<T extends Dialog> {
+    childComponentType: Type<T>;
+    closeModal: (result?: any) => void;
+    titleTemplateRef$ = new Subject<TemplateRef<any>>();
+    buttonsTemplateRef$ = new Subject<TemplateRef<any>>();
+    options?: ModalOptions<T>;
+
+    /**
+     * This callback is invoked when the childComponentType is instantiated in the
+     * template by the {@link DialogComponentOutletComponent}.
+     * Once we have the instance, we can set the resolveWith function and any
+     * locals which were specified in the config.
+     */
+    onCreate(componentInstance: T) {
+        componentInstance.resolveWith = (result?: any) => {
+            this.closeModal(result);
+        };
+        if (this.options && this.options.locals) {
+            // tslint:disable-next-line
+            for (const key in this.options.locals) {
+                componentInstance[key] = this.options.locals[key] as T[keyof T];
+            }
+        }
+    }
+
+    /**
+     * This should be called by the {@link DialogTitleDirective} only
+     */
+    registerTitleTemplate(titleTemplateRef: TemplateRef<any>) {
+        this.titleTemplateRef$.next(titleTemplateRef);
+    }
+
+    /**
+     * This should be called by the {@link DialogButtonsDirective} only
+     */
+    registerButtonsTemplate(buttonsTemplateRef: TemplateRef<any>) {
+        this.buttonsTemplateRef$.next(buttonsTemplateRef);
+    }
+
+    /**
+     * Called when the modal is closed by clicking the X or the mask.
+     */
+    modalOpenChange(status: any) {
+        if (status === false) {
+            this.closeModal();
+        }
+    }
+}

+ 107 - 0
admin-ui/src/app/shared/providers/modal/modal.service.ts

@@ -0,0 +1,107 @@
+import { ComponentFactoryResolver, Injectable, ViewContainerRef } from '@angular/core';
+import { Type } from '@angular/core/src/type';
+import { Observable, of } from 'rxjs';
+import { OverlayHostService } from '../../../core/providers/overlay-host/overlay-host.service';
+import { ModalDialogComponent } from '../../components/modal-dialog/modal-dialog.component';
+
+/**
+ * Any component intended to be used with the ModalService.fromComponent() method must implement
+ * this interface.
+ */
+export interface Dialog {
+    /**
+     * Function to be invoked in order to close the dialog when the action is complete.
+     * The Observable returned from the .fromComponent() method will emit the value passed
+     * to this method and then complete.
+     */
+    resolveWith: (result?: any) => void;
+}
+
+/**
+ * Options to configure the behaviour of the modal.
+ */
+export interface ModalOptions<T> {
+    /** Sets the width of the dialog */
+    size?: 'sm' | 'md' | 'lg' | 'xl';
+    /**
+     * When true, the "x" icon is shown
+     * and clicking it or the mask will close the dialog
+     */
+    closable?: boolean;
+    /**
+     * Values to be passed directly to the component.
+     */
+    locals?: Partial<T>;
+}
+
+/**
+ * This service is responsible for instantiating a ModalDialog component and
+ * embedding the specified component within.
+ */
+@Injectable()
+export class ModalService {
+
+    hostView: ViewContainerRef;
+
+    constructor(private componentFactoryResolver: ComponentFactoryResolver,
+                overlayHostService: OverlayHostService) {
+        overlayHostService.getHostView().then(view => {
+            this.hostView = view;
+        });
+    }
+
+    /**
+     * Create a modal from a component. The component must implement the {@link Dialog} interface.
+     * Additionally, the component should include templates for the title and the buttons to be
+     * displayed in the modal dialog. See example:
+     *
+     * @example
+     * ```
+     * class MyDialog implements Dialog {
+     *  resolveWith: (result?: any) => void;
+     *
+     *  okay() {
+     *    doSomeWork().subscribe(result => {
+     *      this.resolveWith(result);
+     *    })
+     *  }
+     *
+     *  cancel() {
+     *    this.resolveWith(false);
+     *  }
+     * }
+     * ```
+     *
+     * ```
+     * <ng-template vdrDialogTitle>Title of the modal</ng-template>
+     *
+     * <p>
+     *     My Content
+     * </p>
+     *
+     * <ng-template vdrDialogButtons>
+     *     <button type="button"
+     *             class="btn"
+     *             (click)="cancel()">Cancel</button>
+     *     <button type="button"
+     *             class="btn btn-primary"
+     *             (click)="okay()">Okay</button>
+     * </ng-template>
+     * ```
+     */
+    fromComponent<T extends Dialog>(component: Type<T>, options?: ModalOptions<T>): Observable<any> {
+        const modalFactory = this.componentFactoryResolver.resolveComponentFactory(ModalDialogComponent);
+        const modalComponentRef = this.hostView.createComponent(modalFactory);
+        const modalInstance = modalComponentRef.instance;
+        modalInstance.childComponentType = component;
+        modalInstance.options = options;
+
+        return new Observable(subscriber => {
+            modalInstance.closeModal = (result: any) => {
+                modalComponentRef.destroy();
+                subscriber.next(result);
+                subscriber.complete();
+            };
+        });
+    }
+}

+ 19 - 1
admin-ui/src/app/shared/shared.module.ts

@@ -9,8 +9,14 @@ import { DataTableColumnComponent } from './components/data-table/data-table-col
 import { DataTableComponent } from './components/data-table/data-table.component';
 import { FormFieldControlDirective } from './components/form-field/form-field-control.directive';
 import { FormFieldComponent } from './components/form-field/form-field.component';
+import { FormItemComponent } from './components/form-item/form-item.component';
+import { DialogButtonsDirective } from './components/modal-dialog/dialog-buttons.directive';
+import { DialogComponentOutletComponent } from './components/modal-dialog/dialog-component-outlet.component';
+import { DialogTitleDirective } from './components/modal-dialog/dialog-title.directive';
+import { ModalDialogComponent } from './components/modal-dialog/modal-dialog.component';
 import { PaginationControlsComponent } from './components/pagination-controls/pagination-controls.component';
 import { TableRowActionComponent } from './components/table-row-action/table-row-action.component';
+import { ModalService } from './providers/modal/modal.service';
 
 const IMPORTS = [
     ClarityModule,
@@ -29,13 +35,25 @@ const DECLARATIONS = [
     TableRowActionComponent,
     FormFieldComponent,
     FormFieldControlDirective,
+    FormItemComponent,
+    ModalDialogComponent,
+    DialogComponentOutletComponent,
+    DialogButtonsDirective,
+    DialogTitleDirective,
 ];
 
 @NgModule({
     imports: IMPORTS,
     exports: [...IMPORTS, ...DECLARATIONS],
     declarations: DECLARATIONS,
-    entryComponents: [],
+    providers: [
+        // This needs to be shared, since lazy-loaded
+        // modules have their own entryComponents which
+        // are unknown to the CoreModule instance of ModalService.
+        // See https://github.com/angular/angular/issues/14324#issuecomment-305650763
+        ModalService,
+    ],
+    entryComponents: [ModalDialogComponent],
 })
 export class SharedModule {