Search code examples
angularangular-materialdialogmodal-dialogabstraction

Angular Material Dialog - Factory Pattern


I need to create a DRY modal abstraction using the angular material dialog component and wanted to use a factory function, creating a new material dialog with: new Modal(component, config, data)

I would like:

  • a modal "model" to handle the configuration of properties
  • a modal "service" to handle the opening and closing of the dialog
  • any component passed into the shared modal component
  • "action" buttons can be passed into the modal as templates

function signature I'd like to use to create these modals/dialogs:

  openModal(): void {
    const testData = { name: "dummy name", address: "dummy address" };
    
    this.modalService.createModal(
      new Modal(
        // component to inject:
        ChildComponent,
        {
          // modal options:
          title: "the custom title"
        },
        {
          // modal config:
          data: { item: testData },
          hasBackdrop: false,
          autoFocus: false
        },
        {
          // modal action "templates":
          actionsRight: componentInstance.instance.modalFooterRightRef,
          actionsLeft: componentInstance.instance.modalFooterLeftRef,
        }
      )
    );
  }
}

Can this be done with a material dialog?


Here's a stackblitz of the current effort: https://angular-dialog-abstraction.stackblitz.io
There's a feature module containing the implementation and a shared module for the abstraction.

Currently the modal:
√ opens
√ injects the component
√ receives data as expected
x works as expected: there is no title or action buttons.


Modal Model
import { Type } from "@angular/core";
import { MatDialogConfig } from "@angular/material/dialog";
import { ModalOptions, ModalTemplates } from "../interfaces/modal";
import { ModalSizeOptions } from "../enums/modal-size-options.enum";

export class Modal {
  static defaultModalOptions: ModalOptions = {
    cancel: true,
    close: false,
    footer: true,
    size: ModalSizeOptions.MEDIUM,
    title: "Attention"
  };

  static defaultModalConfig: MatDialogConfig = {
    data: null,
    ariaDescribedBy: null,
    ariaLabel: null,
    ariaLabelledBy: null,
    autoFocus: true,
    backdropClass: null,
    closeOnNavigation: false,
    componentFactoryResolver: null,
    direction: "ltr",
    disableClose: true,
    hasBackdrop: false,
    height: "",
    id: "",
    maxHeight: null,
    maxWidth: null,
    minHeight: null,
    minWidth: null,
    panelClass: "",
    position: { top: "", bottom: "", left: "", right: "" },
    restoreFocus: true,
    role: null,
    scrollStrategy: null,
    viewContainerRef: null,
    width: ""
  };

  static defaultTemplates: ModalTemplates = {
    error: null,
    header: null,
    content: null,
    actionsLeft: null,
    actionsRight: null
  };

  public component: Type<any>;
  public options = Modal.defaultModalOptions;
  public dialog = Modal.defaultModalConfig;
  public templates: ModalTemplates;

  constructor(
    component: Type<any>,
    options?: ModalOptions,
    dialog?: MatDialogConfig,
    templates?: ModalTemplates
  ) {
    this.component = component;
    this.options = options
      ? Object.assign({}, Modal.defaultModalOptions, options)
      : Modal.defaultModalOptions;
    this.dialog = dialog
      ? Object.assign({}, Modal.defaultModalConfig, dialog)
      : Modal.defaultModalConfig;
    this.templates = templates
      ? Object.assign({}, Modal.defaultTemplates, templates)
      : Modal.defaultTemplates;
  }
}

Modal Component
import { Component, ComponentFactoryResolver, OnInit, Input } from "@angular/core";
import { Modal } from "../../models/modal";

@Component({
  selector: "app-modal",
  styleUrls: ["./modal.component.css"],
  template: `
    <div>
      <header>
        <h1 mat-dialog-title>{{ modal.options.title }}</h1>
        <button mat-icon-button mat-dialog-close="true">
          <i class="material-icons">clear</i>
        </button>
      </header>

      <mat-dialog-content>
        <ng-template #component></ng-template>
      </mat-dialog-content>

      <div *ngIf="modal.options.footer">
        <footer class="flex justify-between w-full">
          <div class="modalFooterLeft">
            <mat-dialog-actions>
              <ng-container
                [ngTemplateOutlet]="modal.templates.actionsLeft"
              ></ng-container>
            </mat-dialog-actions>
          </div>

          <div class="modalFooterRight">
            <mat-dialog-actions>
              <ng-container
                [ngTemplateOutlet]="modal.templates.actionsRight"
              ></ng-container>
              <button
                *ngIf="modal.options.cancel"
                mat-button
                mat-dialog-close
                cdkFocusInitial
              > Cancel </button>

              <button
                 *ngIf="modal.options.close"
                 mat-button
                 mat-dialog-close
              > Close </button>
            </mat-dialog-actions>
          </div>
        </footer>
      </div>
    </div>
  `
})
export class ModalComponent implements OnInit {
  
  @Input() public modal: Modal;

  constructor(
        private componentFactoryResolver: ComponentFactoryResolver,
  ) {}

  ngOnInit() {}
}

Modal Service
import { Injectable } from "@angular/core";
import { MatDialog, MatDialogConfig } from "@angular/material/dialog";
import { Modal } from "../models/modal";

@Injectable()
export class ModalService {
  constructor(public dialog: MatDialog) {}

  public createModal(modal?: Modal): any {
    return this.openModal(modal.component, modal.dialog);
  }

  private openModal(component: any, config: MatDialogConfig): any {
    return this.dialog.open(component, config);
  }
}

Example of passing the action buttons as templates from the child component that is injected into the modal component:

<ng-template #actionsRight>
  <button
    mat-button color="primary"
    [disabled]="f.pristine || !f.valid"
    [loading]="this.state.loading"
    (click)="createItem()"
  >Create</button>
</ng-template>

<ng-template #actionsLeft>
  <button
    mat-button color="primary"
    [disabled]="f.pristine || !f.valid"
    [loading]="this.state.loading"
    (click)="deleteItem()"
  >Delete</button>
</ng-template>

Really appreciate any help, thanks.


Solution

  • I noticed that you're opening ChildComponent directly.

    this.openModal(modal.component, modal.dialog);
    

    If you want to use ModalComponent as a base wrapper for all dynamically created dialogs then you should open that component instead.

    modal.service.ts

    this.dialog.open(ModalComponent, {
      ...modal.dialog,
      data: modal
    });
    

    Here I'm passing dialog related options to MatDialog as well as the whole your Modal model as a data. This data will be available in ModalComponent though DI.

    modal.component.ts

    export class ModalComponent implements OnInit {
          
      constructor(
        ...
        @Inject(MAT_DIALOG_DATA) public modal: Modal
      ) {}
    

    Now, we're inside ModalComponent where we can dynamically create passed ChildComponent into <ng-template #component></ng-template> by using low-level Angular API:

    export class ModalComponent implements OnInit {
      @ViewChild("component", { read: ViewContainerRef, static: true })
      componentTarget: ViewContainerRef;
    
      actionsLeft: TemplateRef<any>;
      actionsRight: TemplateRef<any>;
    
      constructor(
        private componentFactoryResolver: ComponentFactoryResolver,
        private inj: Injector,
        @Inject(MAT_DIALOG_DATA) public modal: Modal
      ) {}
    
      ngOnInit() {
        const factory = this.componentFactoryResolver.resolveComponentFactory(
          this.modal.component
        );
        const componentRef = this.componentTarget.createComponent(
          factory,
          null,
          Injector.create({
            providers: [
              {
                provide: MAT_DIALOG_DATA,
                useValue: this.modal.dialog.data
              }
            ],
            parent: this.inj
          })
        );
    

    After that you can event action buttons defined as templates in ChildComponent:

    export class ModalComponent implements OnInit {
      actionsLeft: TemplateRef<any>;
      actionsRight: TemplateRef<any>;
    
      ...
    
      ngOnInit() {
        ...
        const componentRef = this.componentTarget.createComponent(
          factory,
          ...
        );
        this.actionsLeft = componentRef.instance.actionsLeftRef;
        this.actionsRight = componentRef.instance.actionsRightRef;
      }
    }
    

    Forked Stackblitz