Git Product home page Git Product logo

dynamic-component-article's Introduction

Hi there ๐Ÿ‘‹

I'm Ezz abuzaid a Computer Engineer by specialization, Software Engineer by profession, and an Engineer at heart.

Nowadays, I mainly code TypeScript and used to do C# and Dart. I love working with new languages and technologies wherever an opportunity presents itself.

I love writing and have in-depth disucssions, my ultimate goal beside programming to be A Dev Journalist.

Sharing knowledge is a passion of mine, I do this by writing blog posts and helping newbies.

Technical Articles

Workshops

Trivia

  • ๐Ÿ”ญ I'm working as Software Engineer @Equiti Group
  • ๐Ÿงฌ I read about compilers occasionally.
  • ๐ŸŒฑ Currently building Tiny Injector and SQL Tooling
  • ๐Ÿ“š Currently reading Design for Developers
  • ๐Ÿ‘ฏ Ready to collaborate on open sourceopen-source๐Ÿ’ฌ Here to answer your questions about Web/Mobile Development

Open Source Projects

Project Description
SQL Tooling Simple SQLite Parser Done In TypeScript
Tiny Injector Mix of .NetCore and Angular dependency injection
RFC 7807 Typescript implementation of RFC 7807
Fayona .NetCore like framework using Node.js
Ngx-Request-Options Angular package that make passing properties to interceptors possible
Ngx-Form-Factory Angular package to build complex forms without the need to write any markup boilerplate
Document Storage JavaScript package that provides a unified interface for browser storages
Flutter Form Validators A set of predefined validators that makes field validation straightforward
C# class to ProtoBuf If you're moving from Monolithic to Microservices, you need to convert your models
Docker Compose Environment Variable To JSON Converting docker-compose environment varaibles To JSON

My older Technical Articles you might read

๐Ÿ“ซ Where to find me

dynamic-component-article's People

Contributors

ezzabuzaid avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar

Watchers

 avatar

dynamic-component-article's Issues

Binding

Binding is pretty forward now since we won't have incorrect inputs/outputs names.

Inputs

private bindInputs(componentInputs: ComponentInputs, userInputs: UserInputs, componentInstance: any) {
  componentInputs.forEach((input) => {
      const inputValue = userInputs[input.templateName];
      componentInstance[input.propName] = inputValue;
  });
}

Outputs

takeUntil operator used to unsubscribe from the EventEmitter instance later on.
this.subscription is an instance of Subject, which will be declared in the next sections.

private bindOutputs(componentOutputs: ComponentInputs, userOutputs: UserInputs, componentInstance: any) {
  componentOutputs.forEach((output) => {
      (componentInstance[output.propName] as EventEmitter<any>)
          .pipe(takeUntil(this.subscription))
          .subscribe((event) => {
              const handler = userOutputs[output.templateName];
              if (handler) { // in case the output has not been provided at all
                  handler(event);
              }
          });
  });
}

Set Up

Our directive would be used like this

<ng-template [dynamic-component]="component" [inputs]="{}" [outputs]="{}"> </ng-template>

To complete the setup we need to make sure that

  1. outputs/inputs object corresponds to component outputs/inputs.
  2. ngOnChange runs on input change.
  3. outputs EventEmitter are auto unsubscribed from.

Types that will be used in the code

type UserOutputs = Record<string, (event: any) => void>;
type UserInputs = Record<string, any>;
type ComponentInputs = ComponentFactory<any>['inputs'];
type ComponentOutputs = ComponentFactory<any>['outputs'];

Utility function for strict mode people ๐Ÿ˜…

function assertNotNullOrUndefined<T>(value: T): asserts value is NonNullable<T> {
    if (value === null || value === undefined) {
        throw new Error(`cannot be undefined or null.`);
    }
}

The directive

@Directive({
    selector: '[dynamic-component]',
})
export class DynamicComponentDirective implements OnDestroy, OnChanges {
  @Input('dynamic-component') component!: Type<any>;
  @Input() outputs?: UserOutputs = {};
  @Input() inputs?: UserInputs = {};
  ngOnChanges(changes: SimpleChanges) { }
  ngOnDestroy() { }
}

Example

Time to try it out.

Here's a simple component that displays a color based on input and emits an event when it changes.

import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';

@Component({
  selector: 'app-color-box',
  template: `<div style="height: 250px; width: 250px;" [style.background-color]="backgroundColor"></div>`,
})
export class ColorBoxComponent implements OnChanges {
  @Input() backgroundColor: 'red' | 'blue' | 'green' = 'red';
  @Output() backgroundColorChanges = new EventEmitter<any>();
  
  ngOnChanges(changes: SimpleChanges): void {
    this.backgroundColorChanges.next(changes.backgroundColor);
  }
}

Host component declares <ng-template> with ColorBoxComponent as the dynamic-component with inputs and outputs.
Clicking on Change Color button will invoke ngOnChanges of ColorBoxComponent, just as it should be.

Try to change the input name and you'll see an exception thrown in the console.

import { Component } from '@angular/core';
import { ColorBoxComponent } from './color-box.component';

@Component({
  selector: 'app-root',
  template: `
  <ng-template
   [dynamic-component]="component"
   [inputs]="{backgroundColor: backgroundColor}"
   [outputs]="{backgroundColorChanges: onColorChange.bind(this)}">
  </ng-template>
  <button (click)="changeColor()">Change Color</button>
`,
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  component = ColorBoxComponent;
  backgroundColor: ColorBoxComponent['backgroundColor'] = 'green';

  onColorChange(value: ColorBoxComponent['backgroundColor']) {
    console.log(value, this.backgroundColor);
  }

  changeColor() {
    this.backgroundColor = 'blue';
  }
}

Introduction

Creating dynamic components in angular is a bit misleading due to less support for it

In this article, we will discuss how you can still use inputs and outputs while creating dynamic components using a directive.

If you don't know about dynamic components yet, I recommend this article Dynamically Creating Components with Angular.

Validation

Inputs

Loop over the user-provided inputs, check if each provided input is declared in the component as input. a component input is a field decorated with @Input.

private validateInputs(componentInputs: ComponentInputs, userInputs: UserInputs) {
  const userInputsKeys = Object.keys(userInputs);
  userInputsKeys.forEach(userInputKey => {
      const componentHaveThatInput = componentInputs.some(componentInput => componentInput.templateName === userInputKey);
      if (!componentHaveThatInput) {
          throw new Error(`Input ${ userInputKey } is not ${ this.component.name } input.`);
      }
  });
}

Outputs

Loop over the component outputs, check if each output holds an instance of EventEmitter. a component output is a field decorated with @Output and has EventEmitter instance as value.

in the other part we perform a loop over the user-provided outputs, check if each provided output is declared in the component as Output and if the user-provided output is function. that function will be used as EventEmitter handler.

private validateOutputs(componentOutputs: ComponentOutputs, userOutputs: UserOutputs, componentInstance: any) {
  componentOutputs.forEach((output) => {
      if (!(componentInstance[output.propName] instanceof EventEmitter)) {
          throw new Error(`Output ${ output.propName } must be a typeof EventEmitter`);
      }
  });

  const outputsKeys = Object.keys(userOutputs);
  outputsKeys.forEach(key => {
      const componentHaveThatOutput = componentOutputs.some(output => output.templateName === key);
      if (!componentHaveThatOutput) {
          throw new Error(`Output ${ key } is not ${ this.component.name } output.`);
      }
      if (!(userOutputs[key] instanceof Function)) {
          throw new Error(`Output ${ key } must be a function`);
      }
  });
}

Solution

One way to work around the problem is by creating a custom directive that could help as little as possible to facilitate the bindings.

We will use ComponentFactoryResolver to create a component factory that holds metadata about the component inputs and outputs. this metadata will be used to ensure correct properties names of inputs and outputs are used.

const factory = componentFactoryResolver.resolveComponentFactory(ComponentType);

factory has two getters that represent the component inputs and outputs.

/**
 * The inputs of the component.
 */
abstract get inputs(): {
    propName: string;
    templateName: string;
}[];
/**
 * The outputs of the component.
 */
abstract get outputs(): {
    propName: string;
    templateName: string;
}[];```

Each of which has `propName` and `templateName` that corresponds to
```typescript
@Input(templateName) propName;
@Output(templateName) propName;

templateName defaults to propName if not specifed.

The Problem

In order to create a dynamic component, you have to use either ngComponentOutlet directive or ComponentFactoryResolver object, in either way, property and event (inputs and outputs) bindings are not straightforward.
moreover, ngOnChanges won't work.

Creating The Component

Creating dynamic components is done using ComponentFactoryResolver and ViewContainerRef.
First, we create a factory using ComponentFactoryResolver, the factory contains the metadata to perform inputs/outputs validation.

Second, we use that factory to create the component using ViewContainerRef, it also takes the injector, which will be declared later on.

private createComponent() {
  this.componentFactory = this.componentFactoryResolver.resolveComponentFactory(this.component);
  this.componentRef = this.viewContainerRef.createComponent<any>(this.componentFactory, 0, this.injector);
}

The ngOnChanges

So far we can completely create dynamic components, but we can't use ngOnChanges lifecycle since it doesn't react to @Input changes therefore we have to do this manually.

Another way to do this is to change the @Input field that concerned you to have getter and setter, so you can know when a change happens, but it is not a favorable option so let's stick ngOnChanges.

Let's start with creating changes object for the component.
Basically, do a loop over new inputs (currentInputs) and compare each input with the previous one, in case of change we add it as changed input to the changes object

private makeComponentChanges(inputsChange: SimpleChange, firstChange: boolean): Record<string, SimpleChange> {
  const previuosInputs = inputsChange?.previousValue ?? {};
  const currentInputs = inputsChange?.currentValue ?? {};
  return Object.keys(currentInputs).reduce((acc, inputName) => {
  const currentInputValue = currentInputs[inputName];
  const previuosInputValue = previuosInputs[inputName];
  if (currentInputValue !== previuosInputValue) {
      acc[inputName] = new SimpleChange(firstChange ? undefined : previuosInputValue, currentInputValue, firstChange);
  }
  return acc;
  }, {} as Record<string, SimpleChange>);
}

Now, we have to manually call the ngOnChanges from the component instance if the component declared it and pass changes as an argument.
Changing the previous function to have the functionality

ngOnChanges(changes: SimpleChanges): void {
    // ensure component is defined
  assertNotNullOrUndefined(this.component);
  
  let componentChanges: Record<string, SimpleChange>;
  const shouldCreateNewComponent =
      changes.component?.previousValue !== changes.component?.currentValue
      ||
      changes.injector?.previousValue !== changes.injector?.currentValue;
  
  if (shouldCreateNewComponent) {
      this.destroyComponent();
      this.createComponent();
      // (1) 
      componentChanges = this.makeComponentChanges(changes.inputs, true);
  }
  // (2)
  componentChanges ??= this.makeComponentChanges(changes.inputs, false);
  
  assertNotNullOrUndefined(this.componentFactory);
  assertNotNullOrUndefined(this.componentRef);
  
  this.validateOutputs(this.componentFactory.outputs, this.outputs ?? {}, this.componentRef.instance);
  this.validateInputs(this.componentFactory.inputs, this.inputs ?? {});

  // (3)
  if (changes.inputs) {
      this.bindInputs(this.componentFactory.inputs, this.inputs ?? {}, this.componentRef.instance);
  }

  // (4)
  if (changes.outputs) {
      this.subscription.next(); // to remove old subscription
      this.bindOutputs(this.componentFactory.outputs, this.outputs ?? {}, this.componentRef.instance);
  }

  // (5)
  if ((this.componentRef.instance as OnChanges).ngOnChanges) {
      this.componentRef.instance.ngOnChanges(componentChanges);
  }
}
  1. Create changes object with firstChange as true after creating the component.
  2. In case the component didn't change that means only the inputs or outputs did change so we create changes object with firstChange as false.
  3. Rebind the inputs only if they did change.
  4. Rebind the outputs only if they did change.
  5. Calling component ngOnChanges lifecycle with the possible inputs changes.

src/app/dynamic-component.directive.ts

Hello,

This piece of code help me a lot ( it's doing the same as tag in Vue ).

Right now i'm using Angular 13, but import 'ComponentFactory' and 'ComponentFactoryResolver' are set as deprecated.

So is it possible to update this directive to avoid this deprecated stuff ?

Thanks a lot and by the wat great job with this one.

Shaan1974

Combine The Functions

Let's call the functions, ngOnChanges lifecycle will be used to create the component whenever the component input or injector input changes, in that case, we destroy the previous component first then we create the new component.

after that, we perform the validation then bind the inputs and outputs.

private subscription = new Subject();
@Input('dynamic-component') component!: Type<any>;
@Input() outputs?: UserOutputs = {};
@Input() inputs?: UserInputs = {};
@Input() injector?: Injector;

ngOnChanges(changes: SimpleChanges): void {
  // ensure component is defined
  assertNotNullOrUndefined(this.component);
  
  const shouldCreateNewComponent =
      changes.component?.previousValue !== changes.component?.currentValue
      ||
      changes.injector?.previousValue !== changes.injector?.currentValue;
  
  if (shouldCreateNewComponent) {
      this.destroyComponent();
      this.createComponent();
  }
  
  // to make eslint happy ^^
  assertNotNullOrUndefined(this.componentFactory);
  assertNotNullOrUndefined(this.componentRef);

  this.subscription.next(); // to remove old subscription
  this.validateOutputs(this.componentFactory.outputs, this.outputs ?? {}, this.componentRef.instance);
  this.validateInputs(this.componentFactory.inputs, this.inputs ?? {});
  this.bindInputs(this.componentFactory.inputs, this.inputs ?? {}, this.componentRef.instance);
  this.bindOutputs(this.componentFactory.outputs, this.outputs ?? {}, this.componentRef.instance);
}

with that, in place, we have all the required functionality to do what [ngComponentOutlet] can't.

Clean Up

To destroy a component we invoke the destroy method defined in ComponentRef, then we clear ViewContainerRef which holds the actual component, doing so will also remove it from the UI.

private destroyComponent() {
  this.componentRef?.destroy();
  this.viewContainerRef.clear();
}

the cleanup will be performed in ngOnDestroy lifecycle, the subscription is as mentioned previously an instance of Subject that we used to unsubscribe from EventEmitter subscriptions.

ngOnDestroy(): void {
  this.destroyComponent();
  this.subscription.next();
  this.subscription.complete();
}

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.