Playing with dialogs and ng-templates

In this post, I want to explain our experience working with ng-templates inside our modal dialogs, which has been the short path that we have traveled to get here and what conclusions we draw. It really is not a significant change, it does not imply much more code lines, and maybe it helps you to improve your modal dialogs.

In one of our projects (Empresaula), we have some actions that require a modal dialog to improve the usability of our application. For example, the typical alert message: "Are you sure that you want to delete this document?". Until now, for these cases, we used a MatDialog service from Angular Material. We developed a new dialog component for each new requirement. We don't have a considerable number of different dialogs, so it wasn't a priority to improve these components. However, we decided to play with ng-templates, learn a little more about them, and develop a common dialog component to rule them all.

Requirements for our new dialog component:

  • We want a simple component, no logic, no complexity and easy to maintain.
  • We want to create different dialogs easily and quickly.
  • We want a component that helps us to sustain the style for all the modal dialogs.

Label + buttons array

The first approach was a component that accepted a label text for the header, and an array of buttons for the actions. When the dialog was closed, it returned a value corresponding to the clicked button. For each button we needed to define:

  • label: Button text.
  • value: The value of the response when the dialog is closed.
  • cssClass: The class name to know which CSS rules to apply to it (Optional)

Then inside our common component with a ngFor we rendered all the buttons. And here you have the content of our first dialog component:

<div mat-dialog-content>
  <p class="dialog-paragraph">{{ label }}</p>
</div>
<div class="dialog-actions">
  <button mat-button (click)="onCancel()" class="dialog-actions--cancel">
    {{ 'shared.users.dialog.actions.cancel' | translate }}
  </button>
  <button
    *ngFor="let button of buttons"
    mat-button
    class="dialog-actions--success"
    [ngClass]="button.cssClass"
    (click)="onConfirm(button.value)"
  >
    {{ button.label }}
  </button>
</div>

We already had our common dialog component, but it was not a right solution:

  • It scales poorly: What happens if some modal needs to render an input of type text? Adding a buttonType in each button will solve it, but for every new requirement, we would need to add logic to our component. Add complexity to the component is the main point that we want to avoid.
  • Requires a lot of code to generate buttons: To render the buttons list, it's needed to set a lot of data (label, value, cssClass, buttonType in a future, etc.). At empresaula, we have some components that can open five types of a modal dialog, each type with different buttons.
  • It's not useful for every case: In some dialogs, we render an entire form inside the dialog, with different steps. How is it supposed to build a form using our array buttons variable?

Is ng-templates the right solution?

Yes! Using ng-templates we have removed all the logic from the dialog component, we can render anything inside the dialog, and building extra common components we can maintain the style of the dialogs. Also, we have some extra advantages. Let's take a look.

Our entire dialog component now looks like this:

import { Component, Inject, TemplateRef } from '@angular/core'
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material'

/**
 * A common component rendered as a Material dialog
 */
@Component({
  selector: 'esm-dialog',
  styleUrls: ['dialog.component.scss'],
  template: `
    <div mat-dialog-content>
      <p class="dialog-paragraph">{{ data.headerText }}</p>
      <ng-container [ngTemplateOutlet]="data.template"></ng-container>
    </div>
  `
})
export class DialogComponent<T> {
  /**
   * Initializes the component.
   *
   * @param dialogRef - A reference to the dialog opened.
   */
  constructor(
    public dialogRef: MatDialogRef<DialogComponent<T>>,
    @Inject(MAT_DIALOG_DATA)
    public data: {
      headerText: string
      template: TemplateRef<any>
      context: T
    }
  ) {}
}

An elementary component that renders a header, the content, and stores the context to manage when it's closed.

To help us to build and manage the dialog, we also developed a dialog service and a dialog factory. The factory builds the dialog, and the service manages it.

The Factory

import { Injectable } from '@angular/core'
import { MatDialog, MatDialogConfig } from '@angular/material'
import { first } from 'rxjs/operators'

// Components
import { DialogComponent } from '../components/dialog/dialog.component'

// Models
import { DialogData } from '../models/dialog-data.model'
import { DialogOptions } from '../models/dialog-options.model'

// Services
import { DialogService } from './dialog.service'

@Injectable({
  providedIn: 'root'
})
export class DialogFactoryService<T = undefined> {
  constructor(private dialog: MatDialog) {}

  open(
    dialogData: DialogData<T>,
    options: DialogOptions = { width: 500, disableClose: true }
  ): DialogService<T> {
    const dialogRef = this.dialog.open<DialogComponent<T>, DialogData<T>>(
      DialogComponent,
      {
        ...this.fetchOptions(options),
        data: dialogData
      }
    )

    dialogRef.afterClosed().pipe(first())

    return new DialogService(dialogRef)
  }

  private fetchOptions({
    width,
    disableClose
  }: DialogOptions): Pick<
    MatDialogConfig<DialogData<T>>,
    'width' | 'disableClose'
  > {
    return {
      width: `${width}px`,
      disableClose
    }
  }
}

The Service

import { TemplateRef } from '@angular/core'
import { MatDialogRef } from '@angular/material'
import { first } from 'rxjs/operators'

// Components
import { DialogComponent } from '../components/dialog/dialog.component'

type DialogRef<T> = MatDialogRef<DialogComponent<T>>

export class DialogService<T = undefined> {
  opened$ = this.dialogRef.afterOpened().pipe(first())

  constructor(private dialogRef: DialogRef<T>) {}

  get context() {
    return this.dialogRef.componentInstance.data.context
  }

  close() {
    this.dialogRef.close()
  }

  setHeaderText(headerText: string): void {
    this.dialogRef.componentInstance.data.headerText = headerText
  }

  setTemplate(template: TemplateRef<any>): void {
    this.dialogRef.componentInstance.data.template = template
  }
}

And finally, whenever we need we can create a dialog with a few lines of code.

Step 1: Define the template

<ng-template #userDialogTemplate>
  <esm-user-dialog-template
    [action]="selectedAction"
    (onDispatchAction)="dispatchAction($event)"
  ></esm-user-dialog-template>
</ng-template>

Step 2: Define the template variables, the dialog service and the dialog factory

dialog: DialogService;
@ViewChild("userDialogTemplate")
userDialogTemplate: TemplateRef<any>;

constructor(private dialogFactoryService: DialogFactoryService) {}

Step 3: Open the dialog

this.dialog = this.dialogFactoryService.open({
  headerText: 'Header text',
  template: this.userDialogTemplate
})

Generating the content using ng-templates implies that you can control the dialog component from the component that opens it.

The main difference in this approach is that the onDispatchAction is defined where the common dialog is opened, not inside the common dialog component. It seems a small difference, but it has exciting connotations.

The dialog does not even have an action to close itself, so we don't need to subscribe to the function which opens the dialog.

Additionally, by linking it with the utilities that ng-templates give us, we realized the power that our new component had. We can change the content of the modal at any time during the process. For example, we can dispatch an action from the store, change the template to show a loader while the action is processed, and when we get a response from the store we can choose if we close the dialog or we show some advice content. So we have defined two actions at dialogs service to change the header and the template when it's necessary setHeaderText and setTemplate.

// Change the header of the dialog
this.dialog.setHeaderText('New header')

// Change the content of the dialog
this.dialog.setTemplate(this.userLoadingActions)

// Finally close the dialog
const { activity } = this.dialog.context // Get context before close
this.dialog.close()

Our dialog working and changing the templates in each action.

Conclusions

  • We keep the logic out of our common modal dialog. The control of the modal actions depends on the component that calls it.
  • We don't need to propagate events outside the modal dialog.
  • We can create any modal dialog without adding new features to our common component
  • We can change the content of our dialog at any time.