2

My Angular 8 app uses a service class that wraps the Angular Material dialog implementation and renders dialogs, based on a number of different component types. Here's a simplified version of it:

@Injectable()
export class MyService {
    renderDialogTypeOne() {
        // implementation here calls
        // matDialog.open(TypeOne)
    }

    renderDialogTypeTwo() {
        // implementation here calls
        // matDialog.open(TypeTwo)
    }
}

Since this service class references the types that it renders, it has a dependency on them. However, one of the rendered types (TypeTwo below) also has the above service injected into its constructor, in order that it can launch its own dialogs of TypeOne:

export class TypeOne {
}

export class TypeTwo {
    contructor(private service: MyService) { }

    showNestedDialog() {
        this.service.renderDialogTypeOne();
    }
}

As a result, it appears that there is a circular dependency between the service class and TypeTwo. I understand that I could resolve this by separating the service class into multiple parts and only referencing the parts that are required in a given context, but it doesn't seem right to split a class purely to solve a compiler warning.

Is this really a circular dependency? If so, doesn't the same problem exist in many other scenarios, where two entities have a chicken / egg relationship?

Is there any reasonable solution, other than disabling Angular's circular dependency warnings?

Christopher Peisert
  • 21,862
  • 3
  • 86
  • 117
Tim Coulter
  • 8,705
  • 11
  • 64
  • 95
  • I'm trying to find a way of reasoning about it without extra details. Generally, a circular dependency issue means your architecture can be improved. Elements requiring each other can generally be rearranged so they both have a common requirement. For example, in your implementation, could `renderDialogTypeTwo` pass `renderDialogTypeOne` to the new dialog instance? – Will Alexander Aug 15 '19 at 16:31
  • Circular dependency error protects you from spagheti code. Yes, I have bumped also into this and rewriting your app in a consistent way will help you a lot. Why not extending your dialog types from a parent? – Michelangelo Aug 15 '19 at 20:11

1 Answers1

3

The Angular Material source code for Dialog shows that an Injector is used to instantiate the Component to be displayed in the Dialog. This approach breaks circular dependencies.

Therefore, the circular dependency warning appears to be a false positive.

Circular dependency warnings may be disabled by updating angular.json. Unfortunately, this option is not available per file.

angular.json

....
  "defaults": {
    ....
    "build": {
      "showCircularDependencies": false
    }
  }

Workaround

The solution below allows for nested calls where a Dialog of component type DialogYesNoComponent may open a Dialog of component type DialogWarningComponent and vice versa.

Example

import { DialogService, DialogYesNoComponent, DialogWarningComponent } from '...'


export class TypeOne {
  constructor(private dialog_service: DialogService) { }

  showYesNoDialog() {
    const dialog_question = "Would you like to continue?";
    const dialog_ref: MatDialogRef<DialogYesNoComponent> =
      this.dialog_service.open_yes_no_dialog({
        question: dialog_question,
        title: 'Confirm', height: '300px' })
    dialog_ref.afterClosed().subscribe(
      (choice: 'yes' | 'no') => {
        if (choice === 'yes') {
          // Continue
        } else {
          // Open Nested Dialog
          this.showWarningDialog("Stopping the program.");
        }
      }
    )
  }

  showWarningDialog(warning: String) {
    ...
  }
}

DialogService

import { ElementRef, Injectable } from '@angular/core';
import { MatDialog, MatDialogRef } from '@angular/material';

import { DialogWarningComponent } from './dialog-warning/dialog-warning.component';
import { DialogYesNoComponent } from './dialog-yes-no/dialog-yes-no.component';

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

  public open_yes_no_dialog({ question, title = 'Confirm', yes_button_first = true,
    has_backdrop = false, height = '250px', width = '350px' }:
    {
      question: string, title?: string, yes_button_first?: boolean, has_backdrop?: boolean,
      height?: string, width?: string
    }): MatDialogRef<DialogYesNoComponent> {

    const dialog_ref = this.dialog.open(DialogYesNoComponent, {
      autoFocus: true,
      backdropClass: 'cdk-overlay-transparent-backdrop',
      closeOnNavigation: true,
      disableClose: false,
      hasBackdrop: has_backdrop,
      height: height,
      width: width,
      data: { question: question, title: title, yes_button_first: yes_button_first }
    })

    return dialog_ref
  }

  public open_warning_dialog() {
    { warning, title = 'Warning',
    has_backdrop = false, height = '250px', width = '350px' }:
    {
      warning: string, title?: string, has_backdrop?: boolean,
      height?: string, width?: string
    }): MatDialogRef<DialogWarningComponent> {

    const dialog_ref = this.dialog.open(DialogWarningComponent, {
      autoFocus: true,
      backdropClass: 'cdk-overlay-transparent-backdrop',
      closeOnNavigation: true,
      disableClose: false,
      hasBackdrop: has_backdrop,
      height: height,
      width: width,
      data: { warning: warning, title: title }
    })

    return dialog_ref
  }
}

DialogYesNoComponent

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


export interface YesNoDialogOptions {
  question: string
  title: string
  yes_button_first: boolean
}


@Component({
  selector: 'dialog-yes-no',
  templateUrl: './dialog-yes-no.component.html',
  styleUrls: ['./dialog-yes-no.component.css']
})
export class DialogYesNoComponent {
  constructor(public dialog_ref: MatDialogRef<DialogYesNoComponent>,
    @Inject(MAT_DIALOG_DATA) public options: YesNoDialogOptions) { }
}

DialogWarningComponent

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


export interface WarningDialogOptions {
  warning: string
  title: string
}


@Component({
  selector: 'dialog-warning',
  templateUrl: './dialog-warning.component.html',
  styleUrls: ['./dialog-warning.component.css']
})
export class DialogWarningComponent {
  constructor(public dialog_ref: MatDialogRef<DialogWarningComponent>,
    @Inject(MAT_DIALOG_DATA) public options: WarningDialogOptions) { }
}
Community
  • 1
  • 1
Christopher Peisert
  • 21,862
  • 3
  • 86
  • 117
  • Thanks for your suggestion. From what I can see, your approach is very similar to mine, except that your service only currently supports one component type. If you wanted to add support for a second component type that needs to launch its own (nested) instances of DialogYesNoComponent, I think it would need a reference to DialogService. Wouldn't you then have the same problem as I do? – Tim Coulter Aug 15 '19 at 18:59
  • no, the goal is simply that one dialog is able to launch the other in a nested (stacked) manner. For example, dialog A may launch dialog B to collect supplementary input. In your amended example, if you wanted to achieve this behaviour, I think you would need to extend the constructor of either DialogYesNoComponent or DialogWarningComponent to accept an injected DialogService, and this is where the circular dependency occurs. – Tim Coulter Aug 15 '19 at 20:25
  • @TimCoulter See updated example, where `showYesNoDialog()` calls `showWarningDialog()` and avoids a circular dependency. – Christopher Peisert Aug 15 '19 at 21:03
  • 1
    Thanks @ChristopherPeisert - that certainly seems to solve the problem. Very clever :) ! – Tim Coulter Aug 16 '19 at 05:05