Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom Event rendering using ng-template #204

Closed
Blackbaud-SeanStephens opened this issue May 31, 2019 · 23 comments
Closed

Custom Event rendering using ng-template #204

Blackbaud-SeanStephens opened this issue May 31, 2019 · 23 comments
Milestone

Comments

@Blackbaud-SeanStephens
Copy link

Please consider support for defining custom event rendering using ng-template definitions, preferably by View.

This prevents needing to use HTMLElement manipulation directly (which is an Angular anti-pattern), and makes the use of styles defined per angular component possible.

@irustm
Copy link
Member

irustm commented Jul 3, 2019

please write down how you would like to see it, and at what level?

@daniel-cmd
Copy link

daniel-cmd commented Apr 10, 2020

I'd like to see support for Angular templates as well, similar to the support for templates/slots/components in the Vue & React plugins in v5.

For example, "Angularizing" the Vue example in the v5 changelog, you might have an fcEventContent directive that would render the template in the eventContent Content Injection input:

<full-calendar[options]='calendarOptions'>
  <ng-template fcEventContent let-arg='arg'>
    <b>{{ arg.timeText }}</b>
    <i>{{ arg.event.title }}</i>
  </ng-template>
</full-calendar>

This would also make it a lot easier to use an Angular component to render events. In v5 right now you have to manually build and destroy the template views... something like this:

@Component({
  selector: "my-calendar"
  template: `
    <FullCalendar [options]='calendarOptions'>
    </FullCalendar>
    <ng-template #fcEventContent let-arg='arg'>
      <b>{{ arg.timeText }}</b>
      <i>{{ arg.event.title }}</i>
    </ng-template>
  `
})
export class CalendarComponent {
  
  public readonly calendarOptions = {
    eventContent: (arg) => renderEventContent(arg),
    eventWillUnmount: (arg) => unrenderEvent(arg)
  }
  
  // Connects to the `<ng-template>` in our template.
  @ViewChild("fcEventContent") eventContent: TemplateRef<any>;
  // To prevent memory leaks, we need to manually destroy any views we create when the
  // events are removed from the view.
  private readonly contentRenderers = new Map<string, EmbeddedViewRef<any>>();
  
  renderEventContent(arg) {
    let renderer = this.contentRenderers.get(arg.event.id)
    if (!renderer) {
      // Make a new renderer and save it so that we can destroy when the event is unmounted.
      renderer = this.eventContent.createEmbeddedView({ arg: arg });
      this.contentRenderers.set(arg.event.id, renderer);
    } else {
      // Just update the existing renderer.
      renderer.context.arg = arg;
      renderer.markForCheck();
    }
    renderer.detectChanges();
    return renderer.rootNodes[0];
  }
  
  unrenderEvent(arg) {
    const renderer = this.contentRenderers.get(arg.event.id);
    if (renderer) {
      renderer.destroy();
    }
  }
}

@Ghostbird
Copy link

Ghostbird commented Jul 1, 2020

@daniel-cmd Thanks for your example, I would have liked a better looking solution too. When I use your example, I have to force periodic change detection upon the rendered views:


export class CalendarComponent
  
  ngDoCheck() {
     contentRenderers.forEach(r => r.detectChanges());
  }
  
}

I load a component which has an *ngIf="item$ | async". Without the forced change detection, the component will never show the loaded item when the observable emits it.

Do you know a more elegant way to render a fully functional Angular component into an event?

This might seem off-topic, but I think the use-case should be considered part of the scope of this issue. In general Angular developers will want to render functional Angular components inside an event.

EDIT: Besides that, I had to change the return of renderEventContent(arg) to: return { domNodes: renderer.rootNodes }

@Ghostbird
Copy link

Ghostbird commented Jul 1, 2020

I tried this approach, but it didn't work either. This is based on https://angular.io/guide/dynamic-component-loader

  @ViewChild(AdDirective, { static: true }) eventComponentHost: AdDirective;

  private readonly contentComponents = new Map<string, ComponentRef<EventContentComponent>>();

  // Arrow notation so we don't have to wrap it to preserve the this scope added to calendar options
  renderEventContent = (arg) => {
    let componentRef = this.contentComponents.get(arg.event.id);
    if (!componentRef) {
      // Make a new component and save it so we can destroy it when the event is unmounted.
      componentRef = this.eventComponentHost.viewContainerRef.createComponent(
        this.componentFactoryResolver.resolveComponentFactory(EventContentComponent)
      );
    }
    componentRef.instance.inputEvent = arg.event.extendedProps.event;
    // Trigger component loading
    componentRef.changeDetectorRef.markForCheck();
    componentRef.changeDetectorRef.detectChanges();
    // Update once entity has been emitted to view.
    componentRef.instance.event$.pipe(timeout(10000), first()).subscribe(() => {
      componentRef.changeDetectorRef.markForCheck();
      componentRef.changeDetectorRef.detectChanges();
    });
    return { domNodes: [componentRef.location.nativeElement] };
  }

  unrenderEvent = (arg) => {
    this.contentRenderers.get(arg.event.id)?.destroy();
    this.contentRenderers.delete(arg.event.id);
  }

For some reason this works even worse. Change detection never fires even once on the dynamic component.

@daniel-cmd
Copy link

daniel-cmd commented Jul 1, 2020

Thanks @Ghostbird, I wrote up the example quickly based on some work I had done to integrate with version 3, so I didn't check the return value carefully enough.

I think that the below service works better with regards to change detection. I originally wrote it for v3, but I adapted it to v5 by just having it return the root nodes, which you can add into an object and return from eventContent. By adding it as a provider in the component that hosts the FullCalendar and injecting it, you get access to that component's ViewContainerRef, which means that the views that we return are part of that component's change detection tree. I send in a comparator so that I can compare our internal appointment reference (as FullCalendar was returning a clone of its Event so it always appeared to change).

I haven't tried this with async, but dynamic context menus, ng-bootstrap popovers & tooltips, and dynamic FontAwesome icons all seem to work OK (since I set the data for the icons in the hosting component, I call templateHelper.markForCheck() to refresh the view... that may only be needed with OnPush change detection though).

There definitely might be better ways of handling this, but this seems to work OK for our use cases.

/**
 * Service to help with managing creating template views in FullCalendar.
 * 
 * Provide this service in the individual components that need it.
 */
@Injectable()
export class TemplateHelperService implements OnDestroy {

    private readonly views = new Map<string, EmbeddedViewRef<any>>();

    constructor(private viewContainerRef: ViewContainerRef) {
    }

    /**
     * Gets the view for the given ID, or creates one if there isn't one
     * already. The template's context is set (or updated to, if the
     * view has already been created) the given context values.
     * @param template The template ref (get this from a @ViewChild of an
     * <ng-template>)
     * @param id The unique ID for this instance of the view. Use this so that
     * you don't keep around views for the same event.
     * @param context The available variables for the <ng-template>. For
     * example, if it looks like this: <ng-template let-localVar="value"> then
     * your context should be an object with a `value` key.
     * @param comparator If you're re-rendering the same view and the context
     * hasn't changed, then performance is a lot better if we just return the
     * original view rather than destroying and re-creating the view.
     * Optionally pass this function to return true when the views should be
     * re-used.
     */
    getView(template: TemplateRef<any>, id: string, context: object,
            comparator?: (v1: any, v2: any) => boolean): EmbeddedViewRef<any> {
        let view = this.views.get(id);
        if (view) {
            if (comparator && comparator(view.context, context)) {
                // Nothing changed -- no need to re-render the component.
                view.markForCheck();
                return view;
            } else {
                // The performance would be better if we didn't need to destroy
                // the view here... but just updating the context and checking
                // changes doesn't work.
                this.destroyView(id);
            }
        }
        view = this.viewContainerRef.createEmbeddedView(template, context);
        this.views.set(id, view);
        view.detectChanges();

        return view;
    }

    /**
     * Generates a view for the given template and returns the root DOM node(s)
     * for the view, which can be returned from an eventContent call.
     * @param template The template ref (get this from a @ViewChild of an
     * <ng-template>)
     * @param id The unique ID for this instance of the view. Use this so that
     * you don't keep around views for the same event.
     * @param context The available variables for the <ng-template>. For
     * example, if it looks like this: <ng-template let-localVar="value"> then
     * your context should be an object with a `value` key.
     * @param comparator If you're re-rendering the same view and the context
     * hasn't changed, then performance is a lot better if we just return the
     * original view rather than destroying and re-creating the view.
     * Optionally pass this function to return true when the views should be
     * re-used.
     */
    getTemplateRootNodes(template: TemplateRef<any>,
                         id: string,
                         context: object,
                         comparator?: (v1: any, v2: any) => boolean) {
        return this.getView(template, id, context, comparator).rootNodes;
    }

    hasView(id: string) {
        return this.views.has(id);
    }

    /**
     * Marks the given view (or all views) as needing change detection.
     * Call `detectChanges` on your component if you need to run change
     * detection synchronously; normally Angular handles that.
     */
    markForCheck(id?: string) {
        if (id) {
            this.views.get(id).markForCheck();
        } else {
            for (const view of this.views.values()) {
                view.markForCheck();
            }
        }
    }

    ngOnDestroy(): void {
        this.destroyAll();
    }

    /**
     * Call this method if all views need to be cleaned up. This will happen
     * when your parent component is destroyed (e.g., in ngOnDestroy),
     * but it may also be needed if you  are clearing just the area where the
     * views have been placed.
     */
    public destroyAll() {
        for (const view of this.views.values()) {
            view.destroy();
        }
        this.views.clear();
    }

    public destroyView(id: string) {
        const view = this.views.get(id);
        if (view) {
            const index = this.viewContainerRef.indexOf(view);
            if (index !== -1) {
                this.viewContainerRef.remove(index);
            }
            view.destroy();
            this.views.delete(id);
        }
    }
}

@Ghostbird
Copy link

Ghostbird commented Jul 2, 2020

@daniel-cmd Thanks a lot! This was the crucial part that we needed:

By adding it as a provider in the component that hosts the FullCalendar and injecting it, you get access to that component's ViewContainerRef, which means that the views that we return are part of that component's change detection tree.

Apparently this doesn't work well with the viewContainerRef from the AdDirective, but works perfectly when using your method.

EDIT: I'm running into a curious issue now. Moving an event on the calendar fires the eventWillUnmount and eventContent handlers both, for the same event. The intention is to destroy the old view, and create a new one. However, the order is not guaranteed. Since the views are stored by id in the helper service (which I need for lookups anyway), sometimes a new view is created, overriding the old one, and then the new one is deleted by the eventWillUnmount that intended to destroy the old view.

EDIT: I've managed to solve that issue by using a hash over [event.id, event.start, even.end] as identifier for the TemplateHelperService. Now I notice that all events are redrawn when I move a single one.

EDIT: In the end I included the timeText in the hash too, the fullcalendar calls the eventContent and eventWillUnmount hooks for each part of multi-day spanning events, but with a different value for timeText. Then in the template I use *ngIf to only render part of the custom template for the event that has isStart set. This way things generally went well.

@V3RON
Copy link

V3RON commented Jul 16, 2020

I'm trying to achieve the exact thing - rendering Angular components as events, but I need to support dragging and resizing. The problem is, when I drag the element far enough to trigger mirror rendering (the overlay going after the pointer) then the custom HTML disappears - rendered event is 'empty' and it stays like this until 'eventContent' callback is triggered. It doesn't occur for plain HTML nodes, only for templates and components. Am I missing something or whole method has a flaw?

It would be nice to see declarative templates, but this functionality need to support plugins like 'interaction'. My experiments sadly didn't succeed.

@definitely-unique-username

@arshaw @irustm Any updates?

@muhammadumairaslam
Copy link

I'm trying to achieve the exact thing - rendering Angular components as events, but I need to support dragging and resizing. The problem is, when I drag the element far enough to trigger mirror rendering (the overlay going after the pointer) then the custom HTML disappears - rendered event is 'empty' and it stays like this until 'eventContent' callback is triggered. It doesn't occur for plain HTML nodes, only for templates and components. Am I missing something or whole method has a flaw?

It would be nice to see declarative templates, but this functionality need to support plugins like 'interaction'. My experiments sadly didn't succeed.

facing same issue when drag the event where it started, empty event is rendered, forcedly refresh the dragging event with mentioned code, because eventDrop triggered not fired due to same day/time.

eventDragStop:(arg)=>{ const event = this.calendarApi.getEventById(arg.event.id); event.setExtendedProp('refresh', true); }

let me know, if any better solution to handle this situation. Thanks

@laserus
Copy link

laserus commented Mar 2, 2021

@irustm @arshaw it is very much needed. The ng-template approach is common for many Angular applications.

For example, I am using angular-callendar npm package, which has eventTemplate input:
https://github.com/mattlewis92/angular-calendar/blob/master/projects/angular-calendar/src/modules/week/calendar-week-view.component.ts#L482

The usage is pretty much straightforward:

<ng-template #customWeekTemplate let-weekEvent="weekEvent">
   <shop-resource-calendar-event [event]="weekEvent?.event">
   </shop-resource-calendar-event>
</ng-template>


<mwl-calendar-week-view *ngSwitchCase="CalendarView.Week"
                        [viewDate]="date"
                        [events]="events"
                        [dayStartHour]="8"
                        [dayEndHour]="22"
                        [eventTemplate]="customWeekTemplate">
</mwl-calendar-week-view>

I understand that you have to connect non-angular to angular app and cannot use pure ngTemplateOutlet, but probably method in this section can help render custom angular template.

@eliashourany
Copy link

eventDragStop:(arg)=>{ const event = this.calendarApi.getEventById(arg.event.id); event.setExtendedProp('refresh', true); }

@muhammadumairaslam facing the same issue but your solution did not work for me somehow, did you find a better solution

@hananafzal88
Copy link

image
customized day cell in angular full calendar

@oliverguenther
Copy link

oliverguenther commented Feb 15, 2022

eventDragStop:(arg)=>{ const event = this.calendarApi.getEventById(arg.event.id); event.setExtendedProp('refresh', true); }

@muhammadumairaslam facing the same issue but your solution did not work for me somehow, did you find a better solution

There are two problems with drag & drop and angular templates:

  1. When using eventContent rendering and potentially reusing a view from the TemplateHelperService above, it is important to know that FullCalendar removes DOM nodes in this callback https://github.com/fullcalendar/fullcalendar/blob/master/packages/common/src/global-plugins.ts#L61-L84 when destroying the event. This in turn will result in the view being empty.

  2. When starting to move an event, FullCalendar will mirror the source element here: https://github.com/fullcalendar/fullcalendar/blob/master/packages/interaction/src/dnd/ElementMirror.ts#L121. However due to the way the event is destroyed before being recreated, I found that we had a weird timing issue when the angular template was destroyed before the mirror was created.

Our solution to this is to alter the TemplateHelperService to not actively destroy views, but rather move them to something like a detachedViews array and destroy them later. Later could mean a few ms later in a timeout, or in our case whenever re-rendering the calendar which for us happens always after a drop.

Then, it is paramount you pass the correct ID of the view to the helper service. In our case, we keep a separate view for the actively dragging item (which you can identify from the EventContentArg#isDropping property).

In our case, this means that dragging the event around the calendar will destroy + recreate the view in some cases (for example, when using resources and switching lanes), but I found that this is not too heavy in my testing. It can surely be optimized to only recreate the view when we identify FC deleted/detached the DOM nodes

You can find our alterated implementation of the TemplateHelperService here: https://github.com/opf/openproject/blob/dev/frontend/src/app/features/team-planner/team-planner/planner/event-view-lookup.service.ts

and the changes leading up to that implementation here: opf/openproject#10146

@Ghostbird
Copy link

eventDragStop:(arg)=>{ const event = this.calendarApi.getEventById(arg.event.id); event.setExtendedProp('refresh', true); }

@muhammadumairaslam facing the same issue but your solution did not work for me somehow, did you find a better solution

There are two problems with drag & drop and angular templates:

1. When using `eventContent`  rendering and potentially reusing a view from the `TemplateHelperService`  above, it is important to know that FullCalendar removes DOM nodes in this callback https://github.com/fullcalendar/fullcalendar/blob/master/packages/common/src/global-plugins.ts#L61-L84 when destroying the event. This in turn will result in the view being empty.

2. When starting to move an event, FullCalendar will mirror the source element here: https://github.com/fullcalendar/fullcalendar/blob/master/packages/interaction/src/dnd/ElementMirror.ts#L121. However due to the way the event is destroyed before being recreated, I found that we had a weird timing issue when the angular template was destroyed _before_ the mirror was created.

Our solution to this is to alter the TemplateHelperService to not actively destroy views, but rather move them to something like a detachedViews array and destroy them later. Later could mean a few ms later in a timeout, or in our case whenever re-rendering the calendar which for us happens always after a drop.

Then, it is paramount you pass the correct ID of the view to the helper service. In our case, we keep a separate view for the actively dragging item (which you can identify from the EventContentArg#isDropping property).

In our case, this means that dragging the event around the calendar will destroy + recreate the view in some cases (for example, when using resources and switching lanes), but I found that this is not too heavy in my testing. It can surely be optimized to only recreate the view when we identify FC deleted/detached the DOM nodes

You can find our alterated implementation of the TemplateHelperService here: https://github.com/opf/openproject/blob/dev/frontend/src/app/features/team-planner/team-planner/planner/event-view-lookup.service.ts

and the changes leading up to that implementation here: opf/openproject#10146

The solution is what I mentioned above: #204 (comment)

I include the start-time of the event and the timeText in the hash that's used as identifier for the view in the template service. That way, when an event is moved, the previous view and the new view are uniquely identifiable and you avoid any creation/destruction order issues.

@arshaw
Copy link
Member

arshaw commented Dec 2, 2022

@arshaw
Copy link
Member

arshaw commented Dec 6, 2022

Could people please try out the new ng-template content injection? I'll need to hear about some successful usage before moving the beta to an official release.

@oliverguenther
Copy link

Could people please try out the new ng-template content injection? I'll need to hear about some successful usage before moving the beta to an official release.

That's excellent news! Thanks for working on that. I've planned to look at the beta for Angular 15 support. I'll give this a go tomorrow and let you know. Do you want feedback here or prefer some other means?

@Ghostbird
Copy link

Ghostbird commented Dec 7, 2022

I'm sorry, I'd love to try this, but I can't do it right now.

When FullCalendar 6 dropped Angular support, I removed this library from our code. I'll re-add it now that Angular support is back and this feature has landed. When I removed it, I realised that we didn't actually have much use for the Angular bindings that this library added. However the Angular template integration is something we use a lot. I'm currently working on another part of our software for another week or two, so I can't do it right now.

@oliverguenther
Copy link

oliverguenther commented Dec 8, 2022

Hi @arshaw , I was able to replace eventContent with the ng-template content projection in a few lines of changes, that is working great. 💯

I can't seem to get the resourceLabelContent ng-template to work. I couldn't get figure out the routing from the ContentChild to the custom rendering pipeline though to properly debug it. Do you have any pointers on where to look?

I have a minimal repo example here:
https://stackblitz.com/edit/angular-ivy-wbmq1i?file=src%2Fapp%2Fapp.component.ts,src%2Fapp%2Fapp.component.html

@arshaw
Copy link
Member

arshaw commented Dec 8, 2022

Thanks for testing it out @oliverguenther. I've figured out the problem, see #426

@arshaw
Copy link
Member

arshaw commented Dec 15, 2022

This feature has been released in v6.0.0

@arshaw arshaw closed this as completed Dec 15, 2022
@oliverguenther
Copy link

@arshaw only been back to implement the new version now, works perfectly and our v15 upgrade is almost completed as a result of that. Thanks for all the time spent on the upgrade, this is greatly appreciated! 🙇

@Dozorengel
Copy link

Dozorengel commented Aug 30, 2023

@arshaw how can I set different ng-templates for the same content injection area but for different views? I can use ngIf in the template and render different templates, but what can I do in case I need to render default template (no custom render)?
In other words, can I apply ng-template for a specific view only?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Archived in project
Development

No branches or pull requests