Create your component with micro-templates

Hello.
Everyone who wrote on the Angular framework somehow came across (or even worked) with the Angular Material library . This is a very well-written component library capable of flexible styling, which is implemented through the ability to create various themes for your application, with a large set of components for all occasions.

In my daily work, not a single project can do without it.

But besides all the pluses of this library’s flexibility, it’s also possible to draw on the experience of the creators in writing their own components, and this is for me the best manual on best-practice development on Angular .

In this article I want to share with you how you can implement the approach with a complex template that is implemented in the moduleMatTableModule .

As an example, I want to show how to make a list of cards with the ability to add pagination and filters , and as a basis we take the model model of the MatTable component.

Template ( source ):

<tablemat-table [dataSource]="dataSource"class="mat-elevation-z8"><ng-containermatColumnDef="position"><thmat-header-cell *matHeaderCellDef> No. </th><tdmat-cell *matCellDef="let element"> {{element.position}} </td></ng-container><ng-containermatColumnDef="name"><thmat-header-cell *matHeaderCellDef> Name </th><tdmat-cell *matCellDef="let element"> {{element.name}} </td></ng-container><ng-containermatColumnDef="weight"><thmat-header-cell *matHeaderCellDef> Weight </th><tdmat-cell *matCellDef="let element"> {{element.weight}} </td></ng-container><ng-containermatColumnDef="symbol"><thmat-header-cell *matHeaderCellDef> Symbol </th><tdmat-cell *matCellDef="let element"> {{element.symbol}} </td></ng-container><trmat-header-row *matHeaderRowDef="displayedColumns"></tr><trmat-row *matRowDef="let row; columns: displayedColumns;"></tr></table>

After studying the template, it becomes clear that we indicate in the ng-container tags the markup for a specific column of the table, but how does it work inside? It was this question that I asked when I saw this design, partly due to the fact that I did not work with dynamic components. So, let's get started (source code) .

Structure


A set of entities that we need to create. This block diagram illustrates their interaction.

image

Step one


We need a service to register our micro-templates.

@Injectable()
exportclassRegisterPropertyDef<T> {
  // для хранения шаблонов мы будем использовать обычный Map // в качестве ключа - инстанс компонента, он будет всегда уникальный// на случай если сервис будет лежать в глобальном модуле// и вы будите использовать один компонент множество раз
  private store = newMap<ComponentInstance, Map<string, TemplateRef<T>>>();
  setTemplateById(cmp: ComponentInstance, id: string, template: TemplateRef<any>): void {
    const state = this.store.get(cmp) || newMap();
    state.set(id, template);
    this.store.set(cmp, state);
  }
  getTemplate(cmp: ComponentInstance, id: string): TemplateRef<T> {
    returnthis.store.get(cmp).get(id);
  }
}

Second step


Create a directive for registering templates:

@Directive({
  selector: '[providePropertyDefValue]'
})
exportclassProvidePropertyDefValueDirective<T> implementsOnInit{
  @Input() providePropertyDefValueId: string;
  constructor(
    private container: ViewContainerRef,
    private template: TemplateRef<any>, // шаблон в котором определена наша разметка
    private registerPropertyDefService: RegisterPropertyDefService<any>, // сервис созданый выше
    @Optional() private parent: Alias<T[]> // тут у нас хранится ссылка на компонент в котором используются наши карточки 
  ) {}
  ngOnInit(): void {
    this.container.clear(); // этот пункт не обязателен, объясню по ходуthis.registerPropertyDefService.setTemplateById(
      this.parent as ComponentInstance,
      this.providePropertyDefValueId,
      this.template
    );
  }
}

Third step


Create the component:

@Component({
  selector: 'lib-card-list',
  template: `
  <mat-card *ngFor="let source of sources">
    <ul>
      <li *ngFor="let key of displayedColumns">
        <span>{{ findColumnByKey(key)?.label }}</span>
        <span>
          <ng-container
            [ngTemplateOutlet]="findColumnByKey(key)?.template || default"
            [ngTemplateOutletContext]="{ $implicit: source }"
          ></ng-container>
        </span>
      </li>
    </ul>
  </mat-card>
  <ng-template #default></ng-template>
  `,
  styles: [
    'mat-card { margin: 10px; }'
  ]
})
exportclassCardListComponent<T> implementsOnInit, AfterViewInit{
  @Input() defaultColumns: DefaultColumn[];
  @Input() source$: Observable<T[]>;
  displayedColumns = [];
  sources: T[] = [];
  constructor(private readonly registerPropertyDefService: RegisterPropertyDefService<T>,
              private readonly parent: Alias<T[]>) { }
  ngOnInit() {
    this.source$.subscribe((data: T[]) =>this.sources = data);
    this.displayedColumns = this.defaultColumns.map(c => c.id);
  }
  findColumnByKey(key: string): DefaultColumn {
    returnthis.defaultColumns.find(column => column.id === key);
  }
  ngAfterViewInit(): void {
    this.defaultColumns = this.defaultColumns.map(column =>Object.assign(column, {
        template: this.registerPropertyDefService.getTemplate(this.parent as ComponentInstance, column.id)
      })
    );
  }
}

A little explanation, the main work of the component is to enrich the definition of the data structure in the ngAfterViewInit method . Here, after initializing the templates, we update the defaultColumns models with templates.

In the markup, you could pay attention to the following lines -

<ng-container [ngTemplateOutlet]="findColumnByKey(key)?.template || default"
            [ngTemplateOutletContext]="{ $implicit: source }"></ng-container>

here a feature is used to pass scope (as in AngularJS) to the markup. That allows you to comfortably declare a variable in our micro templates through the let-my-var construct in which the data will lie.

Using


// app.component.html
<lib-card-list [defaultColumns]="defaultColumns" [source$]="sources$"></lib-card-list><ng-container  *libProvidePropertyDefValue="let element; id: 'id'">
  {{ element.id }}
</ng-container><ng-container *libProvidePropertyDefValue="let element; id: 'title'">
  {{ element.title }}
</ng-container>

Initializing our fresh component, and passing parameters to it.

Template definition via ng-container and our libProvidePropertyDefValue directive .

The most important thing here is
"Let element; id: 'id' »

where element is the scope of the template which is equal to the object with the data from the list,
id is the identifier of the micro-template.

Now I want to return to the providePropertyDefValue directive , to the ngOnInit method

  ngOnInit(): void {
    this.container.clear();
...
}

You can place micro-templates as shown in the example, and “clean” them in the directive, or completely transfer their definition inside the lib-card-list component , therefore the markup will look like this:

<lib-card-list [defaultColumns]="defaultColumns" [source$]="sources$"><ng-container  *libProvidePropertyDefValue="let element; id: 'id'">
     {{ element.id }}
   </ng-container><ng-container *libProvidePropertyDefValue="let element; id: 'title'">
    {{ element.title }}
   </ng-container></lib-card-list>

Objectively, the second use case is more productive.

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
  providers: [{ provide: Alias, useExisting: forwardRef(() => AppComponent) }]
})
exportclassAppComponentextendsAlias<any> {
  title = 'card-list-example';
  defaultColumns: DefaultColumn[] = [
    {
      id: 'id',
      label: 'ID'
    },
    {
      id: 'title',
      label: 'Title'
    }
  ];
  sources$ = of([
    {
      id: 1,
      title: 'Hello'
    },
    {
      id: 2,
      title: 'World'
    }
  ]);
}

Everything is quite elementary here, the only thing to consider is:
providers: [{provide: Alias, useExisting: forwardRef (() => AppComponent)}]
This design is necessary for linking the template and the component that uses them.

In our service, the constructor will receive an instance of the AppComponent component from the injector.

Additionally


In this example, we looked at how to make a component, for repeated use in your projects, for which you can transfer different templates with data, these templates can definitely be anything.

How to improve?


You can add pagination from Angular Material and filtering.

// card-list.component.html
<mat-paginator [pageSize]="5"showFirstLastButton></mat-paginator>

// card-list.component.ts
@ViewChild(MatPaginator) paginator: MatPaginator;
 this.paginator.initialized.subscribe(() => {
   // обновление данных для рендеринга
});
this.paginator.page.subscribe((pageEvent: PageEvent) => {
 // реализация обновления данных при переключении страницы
})

Filtering can be implemented through mat-form-field and, similarly to switching pages during pagination, update data.

That's all. I highly recommend periodically looking into the source code of the angular / material library , in my opinion this is a good opportunity to enhance your knowledge in creating flexible and productive components. Thanks for attention.

Also popular now: