Closed Blackbaud-SeanStephens closed 1 year ago
please write down how you would like to see it, and at what level?
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();
}
}
}
@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 }
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.
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);
}
}
}
@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.
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.
@arshaw @irustm Any updates?
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
@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.
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
customized day cell in angular full calendar
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:
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.
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: https://github.com/opf/openproject/pull/10146
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: https://github.com/fullcalendar/fullcalendar-angular/issues/204#issuecomment-652881716
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.
This has been implemented in v6.0.0-beta.3
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.
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?
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.
Hi @arshaw , I was able to replace eventContent
with the ng-template content projection in a few lines of changes, that is working great. :100:
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
Thanks for testing it out @oliverguenther. I've figured out the problem, see https://github.com/fullcalendar/fullcalendar-angular/issues/426
@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! :bow:
@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?
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.