Search code examples
angulartypescriptviewchildelementref

Creating instance of component and passing to another component rendering as [object HTMLelement]


From my component (ex. Component), I'm trying to instantiate an Angular component (ex. CustomComponent), set some properties, and send it over to a table (ex. CustomTable) for rendering, but I keep getting [object HTMLElement] instead of the rendered element in the table cell. Here's my setup:

Component.html

<custom-table [data]="tableData"...></custom-table>

<custom-component #rowDetailTemplate></custom-component>

Component.ts

@Input() data: Array<CustomDataSource>;
@ViewChild('rowDetailTemplate') template: ElementRef;

public tableData: Array<CustomTableData> = new Array<CustomTableData>();

...

private mapper(dataSource: CustomDataSource): CustomTableData {
    var detailComponent = this.template.nativeElement;
    detailComponent.phone = dataSource.phone;

    var tableRow = new CustomTableData();
    tableRow.textColumn = "test";
    tableRow.detailComponent = detailComponent;

    return tableRow;
}

CustomComponent.html

<div>
    <span>{{phone}}</span>
</div>

CustomComponent.ts

@Component({
    selector: `[custom-component]`,
    templateUrl: 'CustomComponent.html'
})
export class CustomComponent {
    @Input() phone: string;
}

CustomTable.html

<mat-table [dataSource]="dataSource">
    <ng-container matColumnDef...>
        <mat-cell *matCellDef="let element;">
            <div [innerHTML]="element.textColumn"></div>
            <div [innerHTML]="element.detailComponent"></div>
        </mat-cell>
    </ng-container>
</mat-table>

My text column renders fine, its just the custom-component that isn't rendering properly.

Any suggestions?

Note that CustomTable needs to be able to accept any type of component/element in detailComponent, not just my CustomComponent.


Solution

  • Instead of trying to pass the component into the table, I ended up passing the table a ComponentFactory, then the table would take care of instantiating the component from a factory and attaching it to a placeholder once the table was done loading the data (otherwise it would try to attach the component to a placeholder that doesn't exist yet).

    Here is what I ended up with:

    Component.html

    <custom-table [data]="tableData"...></custom-table>
    

    Component.ts

    @Input() data: Array<CustomDataSource>;
    
    public tableData: Array<CustomTableData> = new Array<CustomTableData>();
    ...
    private mapper(dataSource: CustomDataSource): CustomTableData {
        var detailComponentFactory: TableExpandableFactoryColumn = {
                componentFactory: this.componentFactoryResolver.resolveComponentFactory(CustomComponent),
                properties: {
                    "phone": dataSource.phone;
                }
            }    
    
        var tableRow : TableExpandableDataRow = {
            rowId: dataSource.rowID,
            columns: {
                "detailComponentFactory": detailComponentFactory,
                "textColumn": "test"
            }
        }
        return tableRow;
    }
    

    CustomComponent.html

    <div>
        <span>{{phone}}</span>
    </div>
    

    CustomComponent.ts

    @Component({
        selector: `[custom-component]`,
        templateUrl: 'CustomComponent.html'
    })
    export class CustomComponent {
        @Input() phone: string;
    }
    

    CustomTable.html

    <mat-table [dataSource]="dataSource">
        <ng-container matColumnDef...>
            <mat-cell *matCellDef="let row;">
                <div [innerHTML]="row.textColumn"></div>
                <div id="detail-placeholder-{{row.internalRowId}}" className="cell-placeholder"></div>
            </mat-cell>
        </ng-container>
    </mat-table>
    

    CustomTable.ts (the meat of the solution)

    ...
    @Input() data: any;
    public placeholders: { placeholderId: string, factoryColumn: TableExpandableFactoryColumn }[];
    public dataSource: MatTableDataSource<any>;
    ...
    constructor(private renderer: Renderer2,
            private injector: Injector,
            private applicationRef: ApplicationRef) {
    
    }
    ...
    public ngOnChanges(changes: SimpleChanges) {
        if (changes['data']) {
            // Wait to load table until data input is available
            this.setTableDataSource();
            this.prepareLoadTableComponents();
        }
    }
    ...
    private setTableDataSource() {
        this.placeholders = [];
    
        this.dataSource = new MatTableDataSource(this.data.map((row) => {
            let rowColumns = {};
    
            // process data columns
            for (let key in row.columns) {
                if ((row.columns[key] as TableExpandableFactoryColumn).componentFactory != undefined) {
                    // store component data in placeholders to be rendered after the table loads
                    this.placeholders.push({
                        placeholderId: "detail-placeholder-" + row.rowId.toString(),
                        factoryColumn: row.columns[key]
                    });
                    rowColumns[key] = "[" + key + "]";
                } else {
                    rowColumns[key] = row.columns[key];
                }
            }
    
            return rowColumns;
        }));
    }
    
    private prepareLoadTableComponents() {
        let observer = new MutationObserver((mutations, mo) => this.loadTableComponents(mutations, mo, this));
        observer.observe(document, {
            childList: true,
            subtree: true
        });
    }
    
    private loadTableComponents(mutations: MutationRecord[], mo: MutationObserver, that: any) {
        let placeholderExists = document.getElementsByClassName("cell-placeholder"); // make sure angular table has rendered according to data
        if (placeholderExists) {
            mo.disconnect();
    
            // render all components
            if (that.placeholders.length > 0) {
                that.placeholders.forEach((placeholder) => {
                    that.createComponentInstance(placeholder.factoryColumn, placeholder.placeholderId);
                });
            }
        }
    
        setTimeout(() => { mo.disconnect(); }, 5000); // auto-disconnect after 5 seconds
    }
    
    private createComponentInstance(factoryColumn: TableExpandableFactoryColumn, placeholderId: string) {
        if (document.getElementById(placeholderId)) {
            let component = this.createComponentAtElement(factoryColumn.componentFactory, placeholderId);
            // map any properties that were passed along
            if (factoryColumn.properties) {
                for (let key in factoryColumn.properties) {
                    if (factoryColumn.properties.hasOwnProperty(key)) {
                        this.renderer.setProperty(component.instance, key, factoryColumn.properties[key]);
                    }
                }
    
                component.changeDetectorRef.detectChanges();
            }
        }
    }
    
    private createComponentAtElement(componentFactory: ComponentFactory<any>, placeholderId: string): ComponentRef<any> {
        // create instance of component factory at specified host
        let element = document.getElementById(placeholderId);
        let componentRef = componentFactory.create(this.injector, [], element);
        this.applicationRef.attachView(componentRef.hostView);
    
        return componentRef;
    }
    
    ...
    export class TableExpandableFactoryColumn {
        componentFactory: ComponentFactory<any>;
        properties: Dictionary<any> | undefined;
    }
    export class TableExpandableDataRow {
        rowId: string;
        columns: Dictionary<any>;
    }