parent
81957d2fa3
commit
d90935cdc4
@ -0,0 +1,131 @@ |
||||
import { EmbeddedViewRef, Injectable, OnDestroy, TemplateRef, ViewContainerRef } from "@angular/core"; |
||||
|
||||
/** |
||||
* View lookup service for injecting angular templates |
||||
* as fullcalendar event content. |
||||
* |
||||
* Based on the suggestion from Daniel Goldsmith |
||||
* in https://github.com/fullcalendar/fullcalendar-angular/issues/204
|
||||
* |
||||
*/ |
||||
@Injectable() |
||||
export class EventViewLookupService 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:any, |
||||
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:any, |
||||
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():void { |
||||
for (const view of this.views.values()) { |
||||
view.destroy(); |
||||
} |
||||
this.views.clear(); |
||||
} |
||||
|
||||
public destroyView(id:string):void { |
||||
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); |
||||
} |
||||
} |
||||
} |
@ -1,5 +1,19 @@ |
||||
<full-calendar |
||||
#ucCalendar |
||||
*ngIf="calendarOptions" |
||||
[options]="calendarOptions" |
||||
></full-calendar> |
||||
<!-- position: relative added in order for the loading indicator to be positioned correctly --> |
||||
<div class="wp-calendar--container loading-indicator--location" |
||||
[attr.data-indicator-name]="'table'" |
||||
style="position: relative"> |
||||
<ng-container |
||||
*ngIf="(calendarOptions$ | async) as calendarOptions" |
||||
> |
||||
<full-calendar |
||||
#ucCalendar |
||||
*ngIf="calendarOptions" |
||||
[options]="calendarOptions" |
||||
></full-calendar> |
||||
</ng-container> |
||||
<ng-template #resourceContent let-resource="resource"> |
||||
<op-principal |
||||
[principal]="resource.extendedProps.user" |
||||
></op-principal> |
||||
</ng-template> |
||||
</div> |
@ -1,73 +1,266 @@ |
||||
import { |
||||
ChangeDetectionStrategy, |
||||
Component, |
||||
ElementRef, |
||||
SecurityContext, |
||||
TemplateRef, |
||||
ViewChild, |
||||
} from '@angular/core'; |
||||
import { EventInput } from '@fullcalendar/core'; |
||||
import { |
||||
CalendarOptions, |
||||
EventInput, |
||||
} from '@fullcalendar/core'; |
||||
import resourceTimelinePlugin from '@fullcalendar/resource-timeline'; |
||||
import { I18nService } from 'core-app/core/i18n/i18n.service'; |
||||
import { ConfigurationService } from 'core-app/core/config/configuration.service'; |
||||
|
||||
console.log(resourceTimelinePlugin); |
||||
import { FullCalendarComponent } from '@fullcalendar/angular'; |
||||
import { EventViewLookupService } from 'core-app/features/team-planner/team-planner/planner/event-view-lookup.service'; |
||||
import { States } from 'core-app/core/states/states.service'; |
||||
import { StateService } from '@uirouter/angular'; |
||||
import { DomSanitizer } from '@angular/platform-browser'; |
||||
import { WorkPackageViewFiltersService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-filters.service'; |
||||
import { WorkPackagesListService } from 'core-app/features/work-packages/components/wp-list/wp-list.service'; |
||||
import { IsolatedQuerySpace } from 'core-app/features/work-packages/directives/query-space/isolated-query-space'; |
||||
import { WorkPackagesListChecksumService } from 'core-app/features/work-packages/components/wp-list/wp-list-checksum.service'; |
||||
import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; |
||||
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; |
||||
import { OpTitleService } from 'core-app/core/html/op-title.service'; |
||||
import { Subject } from 'rxjs'; |
||||
import { take } from 'rxjs/internal/operators/take'; |
||||
import { WorkPackageCollectionResource } from 'core-app/features/hal/resources/wp-collection-resource'; |
||||
import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; |
||||
import { HalResource } from 'core-app/features/hal/resources/hal-resource'; |
||||
import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; |
||||
|
||||
@Component({ |
||||
selector: 'op-team-planner', |
||||
templateUrl: './team-planner.component.html', |
||||
styleUrls: ['./team-planner.component.sass'], |
||||
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
providers: [ |
||||
EventViewLookupService, |
||||
], |
||||
}) |
||||
export class TeamPlannerComponent { |
||||
calendarOptions = { |
||||
schedulerLicenseKey: 'GPL-My-Project-Is-Open-Source', |
||||
editable: false, |
||||
locale: this.I18n.locale, |
||||
fixedWeekCount: false, |
||||
firstDay: this.configuration.startOfWeek(), |
||||
events: this.calendarEventsFunction.bind(this) as unknown, |
||||
// toolbar: this.buildHeader(),
|
||||
plugins: [ |
||||
resourceTimelinePlugin, |
||||
], |
||||
initialView: 'resourceTimelineWeekDaysOnly', |
||||
height: 500, |
||||
views: { |
||||
resourceTimelineWeekDaysOnly: { |
||||
type: 'resourceTimeline', |
||||
duration: { weeks: 1 }, |
||||
slotDuration: { days: 1 }, |
||||
}, |
||||
}, |
||||
resources: [ |
||||
{ |
||||
id: '1', |
||||
title: 'User 1', |
||||
}, |
||||
], |
||||
}; |
||||
export class TeamPlannerComponent extends UntilDestroyedMixin { |
||||
@ViewChild(FullCalendarComponent) ucCalendar:FullCalendarComponent; |
||||
|
||||
@ViewChild('resourceContent') resourceContent:TemplateRef<unknown>; |
||||
|
||||
calendarOptions$ = new Subject<CalendarOptions>(); |
||||
|
||||
projectIdentifier:string|null = null; |
||||
|
||||
constructor( |
||||
readonly I18n:I18nService, |
||||
readonly configuration:ConfigurationService, |
||||
) {} |
||||
private elementRef:ElementRef, |
||||
private states:States, |
||||
private $state:StateService, |
||||
private sanitizer:DomSanitizer, |
||||
private configuration:ConfigurationService, |
||||
private wpTableFilters:WorkPackageViewFiltersService, |
||||
private wpListService:WorkPackagesListService, |
||||
private querySpace:IsolatedQuerySpace, |
||||
private wpListChecksumService:WorkPackagesListChecksumService, |
||||
private schemaCache:SchemaCacheService, |
||||
private currentProject:CurrentProjectService, |
||||
private titleService:OpTitleService, |
||||
private viewLookup:EventViewLookupService, |
||||
private I18n:I18nService, |
||||
) { |
||||
super(); |
||||
} |
||||
|
||||
ngOnInit() { |
||||
this.setupWorkPackagesListener(); |
||||
this.initializeCalendar(); |
||||
this.projectIdentifier = this.currentProject.identifier; |
||||
} |
||||
|
||||
public calendarResourcesFunction( |
||||
fetchInfo:{ start:Date, end:Date, timeZone:string }, |
||||
successCallback:(events:EventInput[]) => void, |
||||
failureCallback:(error:unknown) => void, |
||||
):void|PromiseLike<EventInput[]> { |
||||
this.querySpace.results.values$().pipe( |
||||
take(1), |
||||
).subscribe((collection:WorkPackageCollectionResource) => { |
||||
const resources = this.mapToCalendarResources(collection.elements); |
||||
successCallback(resources); |
||||
}); |
||||
} |
||||
|
||||
public calendarEventsFunction( |
||||
fetchInfo:{ start:Date, end:Date, timeZone:string }, |
||||
successCallback:(events:EventInput[]) => void, |
||||
failureCallback:(error:any) => void, |
||||
failureCallback:(error:unknown) => void, |
||||
):void|PromiseLike<EventInput[]> { |
||||
successCallback([{ |
||||
title: 'Important todo', |
||||
start: '2021-11-10', |
||||
end: '2021-11-21', |
||||
resourceId: '1', |
||||
allDay: true, |
||||
}]); |
||||
this.querySpace.results.values$().pipe( |
||||
take(1), |
||||
).subscribe((collection:WorkPackageCollectionResource) => { |
||||
const events = this.mapToCalendarEvents(collection.elements); |
||||
successCallback(events); |
||||
}); |
||||
|
||||
this.updateTimeframe(fetchInfo); |
||||
} |
||||
|
||||
private initializeCalendar() { |
||||
this.configuration.initialized |
||||
.then(() => { |
||||
this.calendarOptions$.next({ |
||||
schedulerLicenseKey: 'GPL-My-Project-Is-Open-Source', |
||||
editable: false, |
||||
locale: this.I18n.locale, |
||||
fixedWeekCount: false, |
||||
firstDay: this.configuration.startOfWeek(), |
||||
// toolbar: this.buildHeader(),
|
||||
plugins: [ |
||||
resourceTimelinePlugin, |
||||
], |
||||
initialView: 'resourceTimelineWeekDaysOnly', |
||||
height: 500, |
||||
views: { |
||||
resourceTimelineWeekDaysOnly: { |
||||
type: 'resourceTimeline', |
||||
duration: { weeks: 1 }, |
||||
slotDuration: { days: 1 }, |
||||
}, |
||||
}, |
||||
events: this.calendarEventsFunction.bind(this) as any, |
||||
resources: this.calendarResourcesFunction.bind(this), |
||||
resourceLabelContent: (data:any) => this.renderTemplate(this.resourceContent, data.resource.id, data), |
||||
resourceLabelWillUnmount: (data:any) => this.unrenderTemplate(data), |
||||
}); |
||||
}); |
||||
} |
||||
|
||||
renderTemplate(template:TemplateRef<any>, id:string, data:any):{ domNodes:unknown[] } { |
||||
const ref = this.viewLookup.getView(template, id, data); |
||||
return { domNodes: ref.rootNodes }; |
||||
} |
||||
|
||||
unrenderTemplate(arg:any):void { |
||||
this.viewLookup.destroyView(arg.event.id); |
||||
} |
||||
|
||||
public buildHeader():{ right:string, center:string, left:string } { |
||||
return { |
||||
right: 'dayGridWeek', |
||||
center: 'title', |
||||
left: 'prev,next today', |
||||
public updateTimeframe(fetchInfo:{ start:Date, end:Date, timeZone:string }) { |
||||
const filtersEmpty = this.wpTableFilters.isEmpty; |
||||
|
||||
if (filtersEmpty && this.querySpace.query.value) { |
||||
// nothing to do
|
||||
return; |
||||
} |
||||
|
||||
const startDate = moment(fetchInfo.start).format('YYYY-MM-DD'); |
||||
const endDate = moment(fetchInfo.end).format('YYYY-MM-DD'); |
||||
|
||||
if (filtersEmpty) { |
||||
let queryProps = this.defaultQueryProps(startDate, endDate); |
||||
|
||||
if (this.$state.params.query_props) { |
||||
queryProps = decodeURIComponent(this.$state.params.query_props || ''); |
||||
} |
||||
|
||||
this.wpListService.fromQueryParams({ query_props: queryProps }, this.projectIdentifier || undefined).toPromise(); |
||||
} else { |
||||
const { params } = this.$state; |
||||
|
||||
this.wpTableFilters.modify('datesInterval', (datesIntervalFilter) => { |
||||
datesIntervalFilter.values[0] = startDate; |
||||
datesIntervalFilter.values[1] = endDate; |
||||
}); |
||||
} |
||||
} |
||||
|
||||
private setupWorkPackagesListener() { |
||||
this.querySpace.results.values$().pipe( |
||||
this.untilDestroyed(), |
||||
).subscribe((collection:WorkPackageCollectionResource) => { |
||||
this.ucCalendar.getApi().refetchEvents(); |
||||
}); |
||||
} |
||||
|
||||
private mapToCalendarEvents(workPackages:WorkPackageResource[]):EventInput[] { |
||||
return workPackages |
||||
.map((workPackage:WorkPackageResource) => { |
||||
if (!workPackage.assignee) { |
||||
return; |
||||
} |
||||
|
||||
const startDate = this.eventDate(workPackage, 'start'); |
||||
const endDate = this.eventDate(workPackage, 'due'); |
||||
|
||||
const exclusiveEnd = moment(endDate).add(1, 'days').format('YYYY-MM-DD'); |
||||
|
||||
return { |
||||
id: workPackage.href + (workPackage.assignee?.href || 'no-assignee'), |
||||
resourceId: workPackage.assignee?.href, |
||||
title: workPackage.subject, |
||||
start: startDate, |
||||
end: exclusiveEnd, |
||||
allDay: true, |
||||
className: `__hl_background_type_${workPackage.type.id}`, |
||||
workPackage, |
||||
}; |
||||
}) |
||||
.filter((event) => !!event) as EventInput[]; |
||||
} |
||||
|
||||
private mapToCalendarResources(workPackages:WorkPackageResource[]) { |
||||
const resources:{ id:string, title:string, user:HalResource }[] = []; |
||||
|
||||
workPackages.forEach((workPackage:WorkPackageResource) => { |
||||
if (!workPackage.assignee) { |
||||
return; |
||||
} |
||||
|
||||
resources.push({ |
||||
id: workPackage.assignee.href, |
||||
title: workPackage.assignee.name, |
||||
user: workPackage.assignee, |
||||
}); |
||||
}); |
||||
|
||||
return resources; |
||||
} |
||||
|
||||
private defaultQueryProps(startDate:string, endDate:string) { |
||||
const props = { |
||||
c: ['id'], |
||||
t: |
||||
'id:asc', |
||||
f: [{ n: 'status', o: 'o', v: [] }, |
||||
{ n: 'datesInterval', o: '<>d', v: [startDate, endDate] }], |
||||
pp: 100, |
||||
}; |
||||
|
||||
return JSON.stringify(props); |
||||
} |
||||
|
||||
private eventDate(workPackage:WorkPackageResource, type:'start'|'due') { |
||||
if (this.schemaCache.of(workPackage).isMilestone) { |
||||
return workPackage.date; |
||||
} |
||||
return workPackage[`${type}Date`]; |
||||
} |
||||
|
||||
private calendarHeight():number { |
||||
let heightElement = jQuery(this.elementRef.nativeElement); |
||||
|
||||
while (!heightElement.height() && heightElement.parent()) { |
||||
heightElement = heightElement.parent(); |
||||
} |
||||
|
||||
const topOfCalendar = jQuery(this.elementRef.nativeElement).position().top; |
||||
const topOfHeightElement = heightElement.position().top; |
||||
|
||||
return heightElement.height()! - (topOfCalendar - topOfHeightElement); |
||||
} |
||||
|
||||
private sanitizedValue(workPackage:WorkPackageResource, attribute:string, toStringMethod:string|null = 'name') { |
||||
let value = workPackage[attribute]; |
||||
value = toStringMethod && value ? value[toStringMethod] : value; |
||||
value = value || this.I18n.t('js.placeholders.default'); |
||||
|
||||
return this.sanitizer.sanitize(SecurityContext.HTML, value); |
||||
} |
||||
} |
||||
|
Loading…
Reference in new issue