1

Tl;dr: How do I provide a visible component as a dependency for a directive? Naturally the component has to get initialized before the directive, but it has to be the same instance that gets displayed when the app later runs across the selector of the component.


Details:

My app.component.html has a structure like so:

app.component.html

<app-navigation></app-navigation>
<router-outlet></router-outlet>

There is a navigation bar at the top which is always visible. The <router-outlet> always displays the currently active component.

I'd now like to allow the components that are rendered in the <router-outlet> to modify the contents of the navigation bar, for example to display additional buttons that fit the currently active component. This should work with a directive, like so:

some.component.html

<div *appTopBar>
  <button>Additional Button</button>
</div>

The additional button should now appear in the navigation bar at the top.

The appTopBar directive looks like this:

top-bar.directive.ts

import {AfterViewInit, Directive, OnDestroy, TemplateRef} from '@angular/core';
import {AppComponent} from '../navigation/navigation.component';

@Directive({
  selector: '[appTopBar]'
})
export class TopBarDirective implements AfterViewInit, OnDestroy {

  constructor(private tmpl: TemplateRef<any>,
              private nav: NavigationComponent) {
  }

  ngAfterViewInit(): void {
    this.nav.setTopBarContent(this.tmpl);
  }

  ngOnDestroy(): void {
    this.nav.setTopBarContent(null);
  }
}

The directive has a dependency on the NavigationComponent and can pass content to the navigation bar via the publicly provided methods setTopBarContent():

navigation.component.ts

import {Component, EmbeddedViewRef, TemplateRef, ViewChild, ViewContainerRef} from '@angular/core';

@Component({
  selector: 'app-navigation',
  templateUrl: './navigation.component.html',
  styleUrls: ['./navigation.component.scss']
})
export class NavigationComponent {

  @ViewChild('topBarContainer',{static: false})
  topBar: ViewContainerRef;
  topBarContent: EmbeddedViewRef<any>;

  constructor() {}

  /**
   * Set the template to be shown in the top bar
   * @param tmpl template, null clears the content
   */
  public setTopBarContent(tmpl: TemplateRef<any>) {
    if (this.topBarContent) {
      this.topBarContent.destroy();
    }
    if (tmpl) {
      this.topBarContent = this.topBar.createEmbeddedView(tmpl);
    }
  }
}

The first issue I ran into was that the NavigationComponent dependency was not available yet, when the TopBarDirective was initialized. I got the following error:

ERROR Error: Uncaught (in promise): NullInjectorError:

StaticInjectorError(AppModule)[TopBarDirective -> NavigationComponent]: StaticInjectorError(Platform: core)[TopBarDirective -> NavigationComponent]:

NullInjectorError: No provider for NavigationComponent!

So obviously the component got initialized after the directive and wasn't available yet.

I tried adding the NavigationComponent to the providers array of the AppComponent and the dependency injection now worked:

@NgModule({
  declarations: [
    NavigationComponent,
    SomeComponent,
    TopBarDirective
  ],
  imports: [
    BrowserModule,
    CommonModule
  ],
  providers: [NavigationComponent]
})
export class AppModule { }

However, it seems there are now two instances of the NavigationComponent. I checked this by generating a random number in the constructor of the NavigationComponent and logging it. The directive definitely has an instance other from the one displayed at the <app-navigation> selector.

Now I know this pattern works somehow. I found it some time ago where it was introduced by some Angular developer, but I unfortunately don't have the source anymore. The working version, however, displays the contents in the AppComponent, so the directive only has a dependency to AppComponent, which seems to get initialized first. Therefore the whole dependency issue does not occur.

How can I make sure the instance of NavigationComponent provided to the TopBarDirective is the same instance that is displayed at the <app-navigation> selector?

codepearlex
  • 544
  • 4
  • 20

2 Answers2

0

I propose you to create a service say TopbarService for this which would be like this.There we will use a BehaviorSubjectto set the template and emit it's latest value.

@Injectable()
export class TopbarService {

  private currentState = new BehaviorSubject<TemplateRef<any> | null>(null);
  readonly contents = this.currentState.asObservable();

  setContents(ref: TemplateRef<any>): void {
    this.currentState.next(ref);
  }

  clearContents(): void {
    this.currentState.next(null);
  }
}

Now in directive inject this service and invoke the service method.

@Directive({
  selector: '[appTopbar]',
})
export class TopbarDirective implements OnInit {

  constructor(private topbarService: TopbarService,
              private templateRef: TemplateRef<any>) {
  }

  ngOnInit(): void {
    this.topbarService.setContents(this.templateRef);
  }
}

In the NavigationComponent component subscribe on the contents behaviorsubject to get the latest value and set the template.

export class NavigationComponent implements OnInit, AfterViewInit {
  _current: EmbeddedViewRef<any> | null = null;

  @ViewChild('vcr', { read: ViewContainerRef })
  vcr: ViewContainerRef;

  constructor(private topbarService: TopbarService,
              private cdRef: ChangeDetectorRef) {
  }

  ngOnInit() {
  }

  ngAfterViewInit() {
    this.topbarService
      .contents
      .subscribe(ref => {
        if (this._current !== null) {
          this._current.destroy();
          this._current = null;
        }
        if (!ref) {
          return;
        }
        this._current = this.vcr.createEmbeddedView(ref);
        this.cdRef.detectChanges();
      });
  }
}

Html of this component would be like this where you place the template.

template: `
    <div class="full-container topbar">
      <ng-container #vcr></ng-container>
      <h1>Navbar</h1>
    </div>
`,
Navoneel Talukdar
  • 4,393
  • 5
  • 21
  • 42
  • Yes, this came to my mind earlier, but I'm still wondering if there is any way to inject the visual `NavigationComponent` into the directive. There must be a way to tell the DI not to instantiate a second `NavigationComponent` but to rather use the one previously injected into the directive? – codepearlex Sep 12 '19 at 08:32
  • I believe injecting componenets in directive directly may not be worth idea in this case. There may be some compelling case but if you see in regards to best practice it should be avoided.That's why angular distinguishes components from services to increase modularity and reusability. In angular guide it's clearly advised to use service as much as possible to take full advantage of DI. – Navoneel Talukdar Sep 12 '19 at 08:38
  • Accepting this, because injecting a component is probably bad practice and should instead be solved using a service. – codepearlex Sep 19 '19 at 09:19
0

To inject a Controller into its Directive, use forwardRef.

Component definition

@Component({
    //...,
    providers:[
    {
      provide: MyController,
      useExisting: forwardRef(() => MyController)
    }]
})
export class MyController {
    //...
}

Directive definition

@Directive({
    //...
})
export class MyDirective {
    constructor(private ctlr: MyController) { }
}

That constructor might need an @Host(); I haven't tested this code.

Andrew Philips
  • 1,950
  • 18
  • 23