Creating a dynamic repeater with ng-content transclusion
Asked Answered
N

1

12

What I'm trying to achieve is a generic component that is bound to an array of arbitrary objects that allows adding and removing rows dynamically when the view of each row is also arbitrarily defined with components by a master component that uses it.

Note that MasterComponent is an arbitrary component that would be implemented for different pages and is intended to be self contained and not defined by any metadata or external source.

What I have so far are the following Components:

RepeaterComponent template:

<input type="button" value="Add" (click)="addRow()">
<div class="repeater" *ngFor="let row of repeaterArray">
    <div class="repeaterRow">
        <input type="button" value="Remove" (click)="removeRow(row.rowId)">
        <ng-content select="row"></ng-content>
     </div>
</div>

MasterComponent template:

<repeater [repeaterArray]="repeaterObj">
    <row>
        <field-textbox [data]="row.name" [label]="'Name'"></field-textbox>
        <field-textbox [data]="row.description" [label]="'Description'"></field-textbox>
    </row>
</repeater>

The <field-textbox> component is a custom component that I use to encapsulate simple inputs that holds some additional data that I need to use.

The MasterComponent holds an object that for this instance looks like this:

repeaterObj = [
{
    "rowId": 1,
    "name": "First brand",
    "description": "First description"
},
{
    "rowId": 2,
    "name": "Second brand",
    "description": "Second description"
},
{
    "rowId": 3,
    "name": "Third brand",
    "description": "Third description"
}
];

This approach has two issues that I can't seem to find a solution for.

  1. The ng-content selector is identical for all the rows when the ngFor duplicates the template which leaves me with only one ng-content transclusion point after rendering.
  2. There is no reference to the row variable from the ngFor declaration in the <field-textbox> transcluded directives so I can't bind the data correctly.

Is there a better approach to implement the RepeaterComponent that would give me the least amount of effort to create more new MasterComponents of different arbitrary structures and different templates?

Neocene answered 27/7, 2016 at 7:57 Comment(0)
J
20

You could use ngTemplateOutlet to achieve it.

Following are the steps in implementing dynamic repeater:

First step is to provide a TemplateRef as a child element of the RepeaterComponent:

<repeater [repeaterArray]="repeaterObj">
  <ng-template>
    ...
  </ng-template>
</repeater>

Second step is to query this template within RepeaterComponent via @ContentChild:

export class RepeaterComponent { 
  @ContentChild(TemplateRef) itemTemplate: TemplateRef<any>;
  ...

Third step is use ngTemplateOutlet to render the template:

@Component({
  selector: 'repeater',
  template: `
    <input type="button" value="Add" (click)="addRow()">
    <div class="repeater" *ngFor="let row of repeaterArray">
        <div class="repeaterRow">
            <input type="button" value="Remove" (click)="removeRow(row.rowId)">
            <ng-template <== this line
                    [ngTemplateOutlet]="itemTemplate"
                    [ngTemplateOutletContext]="{ $implicit: row }">
                </ng-template>
        </div>
    </div>`
})
export class RepeaterComponent { 
  @Input() repeaterArray: Array<any>;
  @ContentChild(TemplateRef) itemTemplate: TemplateRef<any>;
  ...
}

Fourth step is to use reference to the row inside TemplateRef within MasterComponent (just back to our first step):

<repeater [repeaterArray]="repeaterObj">
  <template let-row>
    <field-textbox [data]="row.name" [label]="'Name'"></field-textbox>
    <field-textbox [data]="row.description" [label]="'Description'"></field-textbox>
  </template>
</repeater>

Notice: we are passing ngOutletContext like object with $implicit property.

using the key $implicit in the context object will set it's value as default.

It works as follows:

[ngTemplateOutletContext]="{ $implicit: row }"  ==> <template let-row>

[ngTemplateOutletContext]="{ item: row }"       ==> <template let-row="item">

ngOutletContext is availlable only since Angular 2 version of 2.0.0-rc.2

You could try the corresponding plunkr (updated to 5.0.0)

Joanne answered 27/7, 2016 at 9:59 Comment(6)
Looks like this is exactly what I was looking for, thank you! The only thing that seems to be not working as expected is the two-way binding on the field-textbox component with [(ngModel)]. The following Plunker demonstrates the problem when you try to change the two different input fields with the same binding. In the app component template you can see that the normal <input> binding works as intended and also updates the object bound but changing the <field-textbox> textbox does not apply the binding back to the object.Neocene
Try to use EventEmitter to achieve two-way binding like plnkr.co/edit/XpEDWD75bfnEtibKDKW7?p=previewJoanne
Yep, that works. I would expect the binding to propagate correctly without having to implement a two-way binding on the <field-textbox> component though seeing as the binding works when used outside of a template with [ngTemplateOutlet].Neocene
It's the same behavior like without ngTemplateOutlet plnkr.co/edit/ZP88WQsexq3UQpLTNriA?p=preview. ngTemplateOutlet doesn't make a magic. It is just pass data inside TemplateRefJoanne
Ah, you're right. I was thinking about binding to a defined class instance rather than to a JSON property. Thanks again!Neocene
on the fourth step, I think there's a small mistake and the tag should be "<ng-template let-row..." instead of "<template let-row..."Chip

© 2022 - 2024 — McMap. All rights reserved.