Search code examples
angulartypescriptangular-directive

Angular @ContentChildren not working with directive as selector


In my angular project, I an trying to enumerate the ng-template elements defined in the HTML template, based on a Directive.

I am trying to follow the example given in the answer by Aleš Doganoc in the answer to the question:
Get multiple ng-template ref values using contentChildren in angular 5

I am using Angular 17.2, which is much newer than the version used in the original answer.

The directive is defined as:

import { Directive, TemplateRef, Input } from '@angular/core';

@Directive({
  selector: '[tableColumn]'
})
export class TableColumnDirective 
{
  constructor(public readonly template: TemplateRef<any>) { }
  @Input('tableColumn') columnName: string = '';
}

My app.component.html contains:

<my-table>
  <ng-template tableColumn="firstname" let-firstname>
    <h1>this template is for column firstName</h1>
 </ng-template>
 <ng-template tableColumn="lastName" let-lastname>
    <h1>this template is for column lastName</h1>
 </ng-template>
</my-table>

The component TestComponent has the selector my-table.
It uses the code:

import { AfterContentInit, Component, ContentChildren, QueryList } from '@angular/core';
import { TableColumnDirective } from '../table-column.directive';

@Component({
  standalone: true,
  selector: 'my-table',
  templateUrl: './test-component.html',
  styleUrl: './test-component.scss'
})
export class TestComponent implements AfterContentInit {

  @ContentChildren(TableColumnDirective) columnList!: QueryList<TableColumnDirective>;
  
  ngAfterContentInit(){
    console.log('column template list');
    console.log(this.columnList.toArray());
  }
}

This is, as far as I can tell, identical to the code proposed by Aleš Doganoc in his answer.

My expectation is, that in the method ngAfterContentInit, the member this.columnList should return the templates which have the directive tableColumn.
However, the collection is always empty.

The console output shows:

test-component.ts:15 column template list
test-component.ts:16 Array(0)

My test project is on GitHub at:
https://github.com/PhilJollans/content-children-demo

And available on Stackblitz at:
https://stackblitz.com/github.com/PhilJollans/content-children-demo

What am I doing wrong?

Background information:
I am trying to make a working version of the component ngx-property-grid, which is no longer being maintained.
This component uses this technique to enumerate templates.


Solution

  • I managed to achieve what you're looking for:

    enter image description here

    enter image description here

    To achieve this, we need to edit some parts of the code.

    Directive I removed the constructor parameter since it was unnecessary and declared the directive as standalone:

    @Directive({
        selector: '[tableColumn]',
        standalone: true
    })
    export class TableColumnDirective {
        constructor() { }
        @Input('columnName') columnName = '';
    }
    

    Test.component.html To render the HTML content placed inside the my-table tag, Angular needs to be informed. This is done using ng-content:

    <h1>This is the temp component</h1>
    <ng-content></ng-content>
    

    I've added some references to the app.component.ts :

    import { Component } from '@angular/core';
    import { CommonModule } from '@angular/common';
    
    import { TestComponent } from './test/test-component';
    import { TableColumnDirective } from './table-column.directive';
    
    @Component({
        selector: 'app-root',
        templateUrl: './app.component.html',
        styleUrl: './app.component.scss',
        standalone: true,
        imports: [
            TestComponent,
            CommonModule,
            TableColumnDirective,
        ]
    })
    export class AppComponent { }
    

    And finally, the actual HTML part:

    <my-table>
        <!-- Method A -->
        <h1 tableColumn [columnName]="'firstName'">this template is for column firstName</h1>
    
        <!-- Method B -->
        <ng-template [ngIf]="true" tableColumn [columnName]="'lastName'">
            <h1>this template is for column lastName</h1>
        </ng-template>
    
        <!-- Test A -->
        <ng-template [ngIf]="true" tableColumn="test1" let-test1>
            <h1>this template is for column test1</h1>
        </ng-template>
    
        <!-- Test B -->
        <ng-template tableColumn="test2" let-test2>
            <h1>this template is for column test2</h1>
        </ng-template>
    </my-table>
    

    As you can see, I've provided four scenarios. The first two work as expected, while the other two don't. Let me explain why.

    Method A There isn't much to say honestly here. If you append a directive to an HTML tag, you can use the directive's property just like you would for @Input.

    Method B This one is essentially your original code, with the addition of [ngIf]="true" and passing the column name as a parameter. The reason the input parameter is the same as in the first case is because of that. The [ngIf]="true" is necessary because Angular doesn't render the <ng-template> by default. For this example, I've just set it to true, but there are better ways to achieve this, such as using ng-container. You can find more details on ng-template here.

    Test A This example kind of works and kind of doesn't. You can see the h1 being rendered, but you won't get the directive's reference since we're passing data in the wrong way. tableColumn="test1" let-test1 doesn't have any meaning in this context. So the ContentChildren in the test.component.ts understands that there is a DOM element, but it doesn't recognize it as the directive.

    Test B As I mentioned in Method B, ng-templates aren't rendered by default, so you won't see it in the UI or in the ContentChildren directive's scope. The reason you see it in the entry list - the last entry - is too complicated to explain here.

    If you need more information, feel free to ask.