11

I'm developing an Angular 7 application which allows management of entities, for example Cars and Students.

The application components can be described by the following tree:

  • Cars
  • Cars/CreateCar (dialog)
  • Students
  • Students/CreateStudent (dialog)

While creating a Car in the CreateCar dialog, the user should be able to create and assign a new student as the owner of the Car using the CreateStudent dialog.

Similarly, while creating a Student in the CreateStudent dialog, the user should be able to create and assign a new car as a property of the Student using the CreateCar dialog.

When compiling, Angular displays: "WARNING in Circular dependency detected" and I understand that this should happen.

I've tried searching for patterns to solve this such as shared services but noone seems to work.

EDIT:

The relevant part of the constructor for both Dialogs:

constructor(
  private readonly matDialog: MatDialog
) {
}

Inside CreateStudent dialog, method that opens the CreateCar dialog:

createCar(): void {
  this.matDialog
    .open(CreateCarDialogComponent)
    .afterClosed().subscribe((car: Car) => {
      // Do something with car
    });
}

Inside CreateCar dialog, method that opens the CreateStudent dialog:

createStudent(): void {
  this.matDialog
    .open(CreateStudentDialogComponent)
    .afterClosed().subscribe((student: Student) => {
       // Do something with student
     });
}

Any suggestions on solving this?

Thank you

EDIT 2:

Demo here https://stackblitz.com/edit/angular-bbfs8k

(Stackblitz doesn't seem to display the compile warning)

compilation warning

Tomás Law
  • 141
  • 1
  • 1
  • 6

6 Answers6

14

The MatDialog doesn't need a direct reference to the component declaration. You only need to pass a ComponentType<any> parameter to open a dialog. So we can resolve the circular dependency (which is triggered by TypeScript) by using the Angular dependency injector.

Create a file named create-card-token.ts and define an injection token.

export const CREATE_CAR_TOKEN: InjectionToken<ComponentType<any>> =
new InjectionToken<ComponentType<any>>('CREATE_CAR_TOKEN');

In your module define the value for the above token as a provider. This is where you define what component will be used for MatDialog.

@NgModule({
    ....
    providers: [
        {provide: CREATE_CAR_TOKEN, useValue: CreateCarComponent}
    ]
}) export class MyModule {}

In the CarComponent you can now inject this token and use it to open the dialog.

@Component({...})
export class CarComponent {
     public constructor(@Inject(CREATE_CAR_TOKEN) private component: ComponentType<any>,
                        private matDialog: MatDialog) {}

     public createCar() {
         this.matDialog
            .open(this.component)
            .afterClosed().subscribe((car: Car) => {
                // Do something with car
            });
     }       
}

This will resolve the circular dependency, because the CarComponent never needs to know the type decliartion of the CreateCarComponent. Instead, it only knows that a ComponentType<any> has been injected, and the MyModule defines what component will be used.

There is one other issue. The above example uses any as the component type that will be create. If you need to gain access to the dialog instance, and call methods directly from the CarComponent, then you can declare an interface type. The key is to keep the interface in a separate file. If you export the interface from the CreateCarComponent file you go back to having circular dependencies.

For example;

  export interface CreateCarInterface {
       doStuff();
  }

You then update the token to use the interface.

export const CREATE_CAR_TOKEN: InjectionToken<ComponentType<CreateCarInterface>> =
new InjectionToken<ComponentType<CreateCarInterface>>('CREATE_CAR_TOKEN');

You can then call doStuff() from the car component like so:

@Component({...})
export class CarComponent {
     public constructor(@Inject(CREATE_CAR_TOKEN) private component: ComponentType<CreateCarInterface>,
                        private matDialog: MatDialog) {}

     public createCar() {
         const ref = this.matDialog.open(this.component);
         ref.componentInstance.doStuff();
     }       
}

You can then implement the interface in the CreateCarComponent.

@Component({..})
export class CreateCarComponent implements CreateCarInterface {
      public doStuff() {
         console.log("stuff");
      }
}

These kinds of circular references happen often with MatDialog and the CDK portal, because we often need to have a service open the dialog, and then the dialog needs to use that same service for other reasons. I've had this happen many times.

Reactgular
  • 52,335
  • 19
  • 158
  • 208
  • Thank you for the detailed answer. It sure seems like an interesting solution and I might use it somewhere else. However, it won't solve my problem because my situation is not the one you described in: "we often need to have a service open the dialog, and then the dialog needs to use that same service for other reasons" – Tomás Law Mar 11 '19 at 11:37
  • My problem is that I have 2 dialog components that need to open one another. CreateCarComponent needs to open CreateStudentComponent and vice-versa. Using the solution you suggested, in my CreateCarComponent, I would need to provide the CREATE_STUDENT_TOKEN using the value of CreateStudentComponent. Similarly, in my CreateStudentComponent, I would need to provide the CREATE_CAR_TOKEN using the value of CreateCarComponent. Therefore, a circular dependency still exists – Tomás Law Mar 11 '19 at 11:42
  • @TomásLaw `CREATE_STUDENT_TOKEN` would never provide `CreateStudentComponent` ever. Those tokens provide only the `ComponentType` and an optional interface. – Reactgular Mar 11 '19 at 12:50
1

Break circularities with a forward class reference (forwardRef)

The order of class declaration matters in TypeScript. You can't refer directly to a class until it's been defined.

This isn't usually a problem, especially if you adhere to the recommended one class per file rule. But sometimes circular references are unavoidable. You're in a bind when class 'A' refers to class 'B' and 'B' refers to 'A'. One of them has to be defined first.

The Angular forwardRef() function creates an indirect reference that Angular can resolve later.

The Parent Finder sample is full of circular class references that are impossible to break.

You face this dilemma when a class makes a reference to itself as does AlexComponent in its providers array. The providers array is a property of the @Component() decorator function which must appear above the class definition.

Break the circularity with forwardRef.

providers: [{ provide: Parent, useExisting: forwardRef(() => AlexComponent) }],

https://angular.io/guide/dependency-injection-in-action#break-circularities-with-a-forward-class-reference-forwardref

0

Suggestions:

  • You could add a flag which indicates, if the opened dialog is a parent or child. If child then the opened dialog doesn't other the create car/child dialog. It interupts the circle.
  • Prior to Create Car/Student dialog you should have a master (assignment manager) which can open those both dialogs. Within the dialogs there is no option to open another dialog.
MoxxiManagarm
  • 8,735
  • 3
  • 14
  • 43
  • 1
    Yes interrupting the cycle to disable the possibility of having infinite dialogs is a runtime issue that can be solved using flags as you said. The problem is at compile time because both CreateCar and CreateStudent components reference each other – Tomás Law Mar 08 '19 at 13:59
  • Having an assignment manager doesn't solve the circular dependency issue. AssignmentManager would need to reference both CreateCar and CreateStudent dialogs to be able to open them. Similarly, CreateCar and CreateStudent would need to inject AssignmentManager to be able to use it to open the new dialog. – Tomás Law Mar 08 '19 at 14:04
0

I don't use the material but I found a solution using ng-bootstrap you can take the same idea and apply it to the material.

Basically I defined a variable in the modal called backComponent, and when I request to open the modal I define which is the backComponent, in ng-boostrap it would be like this:

open() {
   const createStudent = this.modalService.open(CreateStudentDialogComponent);
   createStudent.componentInstance.backComponent = CreateCarDialogComponent;
}

when I need to go back to the previous component like using a button to return to the modal for example I use this backComponent to return:

Pedro Bacchini
  • 876
  • 10
  • 13
0

My Problem:

I have a table component and a button to open the dialog component of the table component. The import cycle error started to occur.

Solution

Instead of creating a separate component.ts file for the dialog component, I moved all of the dialog component.ts code into the table component. Multiple imports of the dialog component were removed and it fixed the problem.

Example

@Component({
  selector: 'table-component',
  templateUrl: 'table-component.html',
})
export class TableComponent {
  constructor(public dialog: MatDialog) {}

  openDialog(): void {
    const dialogRef = this.dialog.open(DialogComponent);

    dialogRef.afterClosed().subscribe(result => {
      console.log('The dialog was closed');
    });
  }
}

@Component({
  selector: 'dialog-component',
  templateUrl: 'dialog-component.html',
})
export class DialogComponent {
  constructor(
    public dialogRef: MatDialogRef<DialogComponent>,
    @Inject(MAT_DIALOG_DATA) public data: DialogData,
  ) {}

  onNoClick(): void {
    this.dialogRef.close();
  }
}
Aamer Shahzad
  • 2,617
  • 1
  • 27
  • 25
-1

create-car-dialog.component.ts imports create-student-dialog.component.ts which imports create-car-dialog.component.ts.

create-student-dialog.component.ts imports create-car-dialog.component.ts which imports create-student-dialog.component.ts.

That is what the error messages mean and there are your circular dependencies. You will need to rectify this in some way.

i.e. you need to stop this importing circle you have created. Perhaps you need a third component to import both of these. There is probably many ways to do it.

Joey Gough
  • 2,753
  • 2
  • 21
  • 42
  • If that third component imports both of them, how would that third component be used in the CreateCarComponent or CreateStudentComponent without having a circular dependency? – Tomás Law Mar 11 '19 at 11:45
  • There are many ways to achieve the same ends. if you want to inject parent component into children use the `@Host` decorator in the constructor. But don't do that... define createCar() and createPerson() in the parent component and pass those methods as properties into children. – Joey Gough Mar 11 '19 at 14:20