DEV Community

Cover image for Inserting Peach component into Rose at runtime
Ayyash
Ayyash

Posted on • Edited on • Originally published at garage.sekrab.com

Inserting Peach component into Rose at runtime

The great Dialog component

The Angular Material Dialog component is-among other things-a component inserted into the body, then another generic component inserted into it. Let's now try to insert a Peach component, in a Rose component after it is being created. First, say hello to Peach. What we want, is a component that receives input, emits output, and has its own events.

// components/peach.partial

// simple peach component
@Component({
  template: `
    Hello {{ peachSomething }} <br>
    <button class="btn-rev" (click)="ok()">Yes Iniside peech</button>
    <button class="btn-rev" (click)="clickPeach()">On peach event</button>
   `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PeachPartialComponent {
  @Input() peachSomething: string = 'default peach';
  @Output() onPeach: EventEmitter<string> = new EventEmitter<string>();

  constructor() {
    console.log('created');
  }
  ok(): void {
    console.log('peach ok');
  }
  clickPeach(): void {
    this.onPeach.emit('peach clicked');
  }
}
Enter fullscreen mode Exit fullscreen mode

Host it

In Rose, we need to create a tag that will host Peach. We can append it to the root element, but that would not be very beneficial, we really want to decide ahead of time where it shall go. So in Rose, we update and add an HTML element (we later will investigate another option).

// rose component updated
template: `
  // ... add a dom for my child
  <div class="box">
    <div id="mychild"></div>
  </div>
  //...
`
Enter fullscreen mode Exit fullscreen mode

So that pretty much looks like appending Peach directly to Rose. Would that work? Let's see.

Append HTML

One way to go about this is gain reference to the mychild element, then append our new component. Straight forward.

// in app root component

// gain reference to my child after creating Rose
const child = (<EmbeddedViewRef<any>>componentRef.hostView).rootNodes[0].querySelector('#mychild')

// create peach
const peachRef = createComponent(PeachPartialComponent, {
    // host peach in mychild reference
    hostElement: child,
    environmentInjector: this.appRef.injector
 });

// attach
this.appRef.attachView(peachRef.hostView);
Enter fullscreen mode Exit fullscreen mode

Let's investigate inputs and outputs and see if it all works the same way,

// listen to outputs of peach
const ss = peachRef.instance.onPeach.subscribe((data: string) => {
    console.log('onpeach called');
});

// assign input:
peachRef.instance.peachSomething = 'new peach something';
Enter fullscreen mode Exit fullscreen mode

This works perfectly fine. Let's put that to use.

Rose service

We are going to make our own dialog service that does the following:

  • Create Rose and insert in body
  • Pass Peach and append to Rose
  • Listen and respond
  • Detach and clean

Working backwards, we need to be able to do the following in our calling component

// components/dialog.component

// somewhere in our code, open dialog using our service
// Dialog in this case is Rose service
const dialog = this.roseService.open(PeachComponent);
// pass few properties to Rose:
dialog.title = "opening peach";
// pass some properties to Peach
dialog.child.prop1 = 'my new peach';
// listen
dialog.onclose.subscribe((data: any) => {
    console.log('closed rose');
});

// we are going to combine these props as we go along
Enter fullscreen mode Exit fullscreen mode

So the Rose service initially needs to create a Rose component when open is called.

// lib/rosedialog/rose.service

// inject in root
@Injectable({ providedIn: 'root' })
export class RoseService {

  constructor(
    // bring in the application ref
    private appRef: ApplicationRef,
    // use platform document
    @Inject(DOCUMENT) private doc: Document
  ) { }

  // open method, will implement
  public open(c: any, options: any) {

    // first create a Rose component
    const componentRef = createComponent(RosePartialComponent, {
      environmentInjector: this.appRef.injector
    });

    // append to body, we will use platform document for this
    this.doc.body.append((<EmbeddedViewRef<any>>componentRef.hostView).rootNodes[0])

    // attach view
    this.appRef.attachView(componentRef.hostView);

    // when closed destroy (onClose is an Output event of Rose)
    const s = componentRef.instance.onClose.subscribe(() => {
      this.appRef.detachView(componentRef.hostView);
      componentRef.destroy();
      s.unsubscribe();
    });

    // gain reference to my child after creating Rose
    const child = (<EmbeddedViewRef<any>>componentRef.hostView).rootNodes[0].querySelector('#rosechild')

    // now create c, and append to rose host view
    const childRef = createComponent(c, {
      environmentInjector: this.appRef.injector,
      hostElement: child
    });

    // attach that as well
    this.appRef.attachView(childRef.hostView);
  }
}

Enter fullscreen mode Exit fullscreen mode

Looking back at the createComponent documentation, the signature's first parameter component is of type: Type<C>Type is an Angular class that refers to the Component type. So our open method can be typed better with Type<any>.

// better typing for c
public open(c: Type<any>, options: any) {

}
Enter fullscreen mode Exit fullscreen mode

Rose herself now should look like a dialog box, I'm removing most of the styling for now for simplicity. Rose has a header with a title, a body, and a close button.

// lib/rosedialog/rose.partial

@Component({
  template: `
    <div class="rose-overlay" >
      <div class="rose">
        <div class="rose-header">
         <h6 class="rose-title" >{{ title }}</h6>
          <button type="button" class="rose-close" (click)="close()">Close</button>
        </div>
        <div class="rose-body" id="rosechild">
          <!-- peach will be inserted here -->
        </div>
      </div>
    </div>
   `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class RosePartialComponent {

  // a property, does not have to be an input
  @Input() title: string = 'Default tile';

  // an event that we will catch to destroy Rose
  @Output() onClose: EventEmitter<any> = new EventEmitter<any>();

  // some public function we can use from within Peach
  close(data: any): void {
    this.onClose.emit(data);
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's test this first before we add properties to Rose.

// components/dialog.component

// in the template
<button (click)="openRose()" class="btn">Open Rose</button>

// in the code behind
constructor(private roseService: RoseService) {
    // it's really is that simple:
    openRose(): void {
    this.roseService.open(PeachPartialComponent, { something: 'anything' });
  }
}
Enter fullscreen mode Exit fullscreen mode

So now we opened a Rose component, with Peach in it. We also closed Rose on a click of a close button. We want to do more, but so far, it really is that simple.

Passing properties to Rose

The first thing we want to do is pass the title property to Rose. Let's pass that in the options param of the open method:

// lib/rosedialog/rose.service

// redifine open method
public open(c: Type<any>, options?: {title?: string}) {
  // ...
  // after attaching RoseView, let's assign title
  this.appRef.attachView(componentRef.hostView);

  // assign title
  componentRef.instance.title = options?.title || '';

  // ...
}
Enter fullscreen mode Exit fullscreen mode

You might think it needs more. Like an interface called IRoseOptions. But we both know that's just bells and whistles. What if we want to pass an object to Peach itself?

Passing data to Peach

The easiest way to do that is simply pass the object to Peach. Is that possible? (Remember from our first article, we can also use Input and setInputs, but setting a public property to instance is simpler)

// rose.service

// add data of type: any
public open(c: Type<any>, options?: {title?: string, data?: any}) {
    // ...

    // after creating the child component, pass properties directly
    childRef.instance.data = options?.data;
}
Enter fullscreen mode Exit fullscreen mode

Peach now should have a definition for data

// peach.component

export class PeachPartialComponent implements OnInit, AfterViewInit {
  // public property to set
  data: any;

  constructor() {
      // this is undefined
    console.log(this.data);
  }

  ngOnInit(): void {
      // this propbably has value
    console.log(this.data);
  }

  ngAfterViewInit(): void {
      // this most definitly has value
    console.log(this.data);
  }
}
Enter fullscreen mode Exit fullscreen mode

We might get access to that data as early as OnInit, but it's generally safer to wait till AfterViewInit. Trying out from our root component

// components/dialog.component

openRose(): void {
  const ref = this.roseService.open(PeachPartialComponent,
    {
      title: 'Peach says hello',
      data: 'some string'
    });
}
Enter fullscreen mode Exit fullscreen mode

Notice, I made a big statement by saying that the data is available on AfterViewInit, but you and I know that Angular is too unpredictable, I have run into situations where I had to pull off some tricks. I hope with the new Angular 16 we will resolve less often to those tricks.

Asking Peach to close Rose

What if I want to call a method from Peach to close Rose? There are two ways to about that, passing a reference to Rose itself to Peach, or creating an event in Peach, and catching it to close Rose.

1. Passing Rose as a reference

One way to do that is to pass a reference to Rose inside of Peach, then the method this.rose.close() can be called inside of Peach.

// rose.service

open(...) {
    // ...
    // we can pass Rose partial to the new component
    childRef.instance.rose = componentRef.instance;
}

// peach.component

export class PeachPartialComponent implements OnInit, AfterViewInit {
    // optionally define data and rose
    data: any;
    rose!: RosePartialComponent;
    // lets use rose to close
    someClick() {
        this.rose.close();
    }

}
Enter fullscreen mode Exit fullscreen mode

You might think this is too clumsy, it is. But it is also that simple and straightforward.

2. Sharing an event with Rose

Another way is to call a predefined event in Peach. In our example we had onPeach event. Let's make another one: onChildClose:

// peach.component

@Output() onChildClose: EventEmitter<any> = new EventEmitter<any>();

// then call it in a method
clickPeach(): void {
  this.onChildClose.emit('close rose');
}
Enter fullscreen mode Exit fullscreen mode

Now all we have to do is subscribe to that event in our Rose services

// rose.service
// ...
open(...) {
  // subscribe to onChildClose if it exists, and destory Rose   
  childRef.instance.onChildClose?.subscribe(() => {
    this.appRef.detachView(componentRef.hostView);
    componentRef.destroy();
  });
}
Enter fullscreen mode Exit fullscreen mode

Too clumsy? We'll figure it out later. Let's move on. How do we listen to events from the outside world?

Emitting events from Rose

The most straightforward way to do it is to assign functions to the options property, then call these functions on events happening in Rose. Here is an example of onclose

// rose.service

// define an extra parameter to options
public open(c: Type<any>, options?: {
    title?: string,
    data?: any,
    // onclose or onemit or onopen ... etc
    onclose?: (data: any) => void}) {

    // then call these functions when you want
    const s = componentRef.instance.onClose.subscribe((data: any) => {

    // call onclose if exists
    if (options?.onclose) {
      options.onclose(data);
    }
    // then destroy
    this.appRef.detachView(componentRef.hostView);
    componentRef.destroy();
    s.unsubscribe();
  });
}
Enter fullscreen mode Exit fullscreen mode

Then in our root component, we use it like this

// components/dialog.component

openRose(): void {
 this.roseService.open(PeachPartialComponent,
    {
      title: 'Peach says hello',
      data: 'some string',
      onclose: () => {
        console.log('closed');
      }
    });

}
Enter fullscreen mode Exit fullscreen mode

This works because we know that onclose should be called on Rose closing, and we can add onopen, or oninit, etc. But what if we just want Peach to emit data?

Handling Peach events

There are two ways for this as well. We either pass the event handler as an option to Rose service (if you wish to be a control freak), or we can directly catch events from Peach, no need to let Rose know about it.

1. Event handlers as options

For example we can wire the onPeach event to be a property for Rose service.

// rose.service

// add yet another function
public open(c: Type<any>, options?: {
  title?: string,
  data?: any,
  onPeach?: (data: any) => void,
  onclose?: () => void}) {

    // at the end, emit subscribe if it exists
    childRef.instance.onPeach?.subscribe((data: any) => {
      options?.onPeach?.(data);
  });
}
Enter fullscreen mode Exit fullscreen mode

Trigger the event in Peach

// peach.partial

// create an event
@Output() onPeach: EventEmitter<string> = new EventEmitter<string>();

// trigger it with some data inside peach
clickPeach(): void {
  this.onPeach.emit('peach clicked');
}
Enter fullscreen mode Exit fullscreen mode

Finally, make use of it in app root

// components/dialog.component

// openRose
openRose(): void {
 this.roseService.open(PeachPartialComponent,
    {
      title: 'Peach says hello',
      data: 'some string',
      onclose: () => {
       console.log('closed');
      },
      // pass custom event handlers
      onPeach: (data: string) => {
        console.log('do seomthing with', data);
      }
    });
}
Enter fullscreen mode Exit fullscreen mode

2. Keep Rose out of it

We can return a reference to the new component (Peach) and deal with it directly, No need to map all properties and events. This is definitely more robust solution, and a lot less strict.

// rose.service

@Injectable({ providedIn: 'root' })
export class RoseService {
  // open method
  public open(...) {
    // ...

    // return refrence to peach
    return childRef.instance;
  }
}
Enter fullscreen mode Exit fullscreen mode

Now in our application, we can do the following

// components/dialog.component

openRose(): void {
  const peachRef =  this.roseService.open(PeachPartialComponent,
  {
    //...
  });

  // its easier to deal with Peach directly:
  peachRef.onPeach.subscribe((data: any) => {
    console.log('do something with', data);
  });
}
Enter fullscreen mode Exit fullscreen mode

So we kept onclose inside Rose service because we want to detach and get rid of Rose before making other calls. But everything else can be done directly on Peach component. There is no need for Rose service to get in between them.

Standalone

Noticed how we did not once add standalone flag to Rose and Peach? That's because we did not need to import any modules. If we are to use a *ngIf for example, we need to turn them into standalone, and import the CommonModule. This is much cleaner than the old solution where we had to house the different components in some module that imports needed modules.

// Peach and Rose should turn into standalone when using common module directives
@Component({
  // ...
  standalone: true,
  imports: [CommonModule]
})
Enter fullscreen mode Exit fullscreen mode

Tidy up

Is it too clumsy? It is. And we can do something about it. But before we go with our housekeeping, there are two other ways besides an HTML node to add content. That, in addition to passing provider services, is coming on next Tuesday. 😴

The Rose service as we stand can be found on StackBlitz

Thank you for reading this far, if you have comments or questions, let me know.

Inserting Peach component into Rose on runtime - Sekrab Garage

Angular programmatically created components. The great Dialog componentThe Angular Material Dialog component is—among other things—a component inserted into the body, then another generic component inserted into it. Let’s now try to insert a .... Posted in Angular

favicon garage.sekrab.com

Top comments (0)