frontend/src/app/features/calendar/op-work-packages-calendar.service.ts
import { Injectable, Injector } from '@angular/core';
import {
CalendarOptions,
DatesSetArg,
DayCellContentArg,
DayCellMountArg,
DayHeaderContentArg,
EventApi,
EventDropArg,
SlotLabelContentArg,
SlotLaneContentArg,
} from '@fullcalendar/core';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { ConfigurationService } from 'core-app/core/config/configuration.service';
import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
import { DomSanitizer } from '@angular/platform-browser';
import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service';
import { splitViewRoute } from 'core-app/features/work-packages/routing/split-view-routes.helper';
import { StateService } from '@uirouter/angular';
import { WorkPackageCollectionResource } from 'core-app/features/hal/resources/wp-collection-resource';
import { ToastService } from 'core-app/shared/components/toaster/toast.service';
import { firstValueFrom, Observable } from 'rxjs';
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 { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin';
import { take } from 'rxjs/operators';
import { HalResourceService } from 'core-app/features/hal/services/hal-resource.service';
import { QueryResource } from 'core-app/features/hal/resources/query-resource';
import {
QueryPropsFilter,
UrlParamsHelperService,
} from 'core-app/features/work-packages/components/wp-query/url-params-helper';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { UIRouterGlobals } from '@uirouter/core';
import { TimezoneService } from 'core-app/core/datetime/timezone.service';
import {
WorkPackagesListChecksumService,
} from 'core-app/features/work-packages/components/wp-list/wp-list-checksum.service';
import { EventReceiveArg, EventResizeDoneArg } from '@fullcalendar/interaction';
import {
HalResourceEditingService,
} from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service';
import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset';
import * as moment from 'moment';
import {
WorkPackageViewSelectionService,
} from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-selection.service';
import { isClickedWithModifier } from 'core-app/shared/helpers/link-handling/link-handling';
import {
uiStateLinkClass,
} from 'core-app/features/work-packages/components/wp-fast-table/builders/ui-state-link-builder';
import { debugLog } from 'core-app/shared/helpers/debug_output';
import {
WorkPackageViewContextMenu,
} from 'core-app/shared/components/op-context-menu/wp-context-menu/wp-view-context-menu.directive';
import { OPContextMenuService } from 'core-app/shared/components/op-context-menu/op-context-menu.service';
import { OpCalendarService } from 'core-app/features/calendar/op-calendar.service';
import { WeekdayService } from 'core-app/core/days/weekday.service';
import { IDay } from 'core-app/core/state/days/day.model';
import { DayResourceService } from 'core-app/core/state/days/day.service';
import allLocales from '@fullcalendar/core/locales-all';
export interface CalendarViewEvent {
el:HTMLElement;
event:EventApi;
}
// The CalenderOptions typings are missing daygrid hooks
interface CalendarOptionsWithDayGrid extends CalendarOptions {
dayGridClassNames:(data:DayCellMountArg) => void;
}
@Injectable()
export class OpWorkPackagesCalendarService extends UntilDestroyedMixin {
static MAX_DISPLAYED = 500;
tooManyResultsText:string|null;
public nonWorkingDays:IDay[] = [];
currentWorkPackages$:Observable<WorkPackageCollectionResource> = this
.querySpace
.results
.values$()
.pipe(
take(1),
);
constructor(
private I18n:I18nService,
private configuration:ConfigurationService,
private sanitizer:DomSanitizer,
private $state:StateService,
readonly injector:Injector,
readonly schemaCache:SchemaCacheService,
readonly toastService:ToastService,
readonly wpTableFilters:WorkPackageViewFiltersService,
readonly wpListService:WorkPackagesListService,
readonly wpListChecksumService:WorkPackagesListChecksumService,
readonly urlParamsHelper:UrlParamsHelperService,
readonly querySpace:IsolatedQuerySpace,
readonly apiV3Service:ApiV3Service,
readonly halResourceService:HalResourceService,
readonly uiRouterGlobals:UIRouterGlobals,
readonly timezoneService:TimezoneService,
readonly halEditing:HalResourceEditingService,
readonly wpTableSelection:WorkPackageViewSelectionService,
readonly contextMenuService:OPContextMenuService,
readonly calendarService:OpCalendarService,
readonly weekdayService:WeekdayService,
readonly dayService:DayResourceService,
) {
super();
}
calendarOptions(additionalOptions:CalendarOptions):CalendarOptions {
return { ...this.defaultOptions(), ...additionalOptions };
}
eventDate(workPackage:WorkPackageResource, type:'start'|'due'):string {
if (this.isMilestone(workPackage)) {
return workPackage.date;
}
return workPackage[`${type}Date`] as string;
}
isMilestone(workPackage:WorkPackageResource):boolean {
return this.schemaCache.of(workPackage).isMilestone as boolean;
}
warnOnTooManyResults(collection:WorkPackageCollectionResource, isStatic = false):void {
if (collection.count < collection.total) {
this.tooManyResultsText = this.I18n.t('js.calendar.too_many',
{
count: collection.total,
max: OpWorkPackagesCalendarService.MAX_DISPLAYED,
});
} else {
this.tooManyResultsText = null;
}
if (this.tooManyResultsText && !isStatic) {
this.toastService.addNotice(this.tooManyResultsText);
}
}
async requireNonWorkingDays(date:Date|string) {
this.nonWorkingDays = await firstValueFrom(this.dayService.requireNonWorkingYear$(date));
}
isNonWorkingDay(date:Date|string):boolean {
const formatted = moment(date).format('YYYY-MM-DD');
return (this.nonWorkingDays.findIndex((el) => el.date === formatted) !== -1);
}
async updateTimeframe(
fetchInfo:{ start:Date, end:Date, timeZone:string },
projectIdentifier:string|undefined,
):Promise<unknown> {
await this.requireNonWorkingDays(fetchInfo.start);
await this.requireNonWorkingDays(fetchInfo.end);
if (this.areFiltersEmpty && this.querySpace.query.value) {
// nothing to do
return Promise.resolve();
}
const startDate = moment(fetchInfo.start).format('YYYY-MM-DD');
const endDate = moment(fetchInfo.end).format('YYYY-MM-DD');
let queryId:string|null = null;
if (this.urlParams.query_id) {
queryId = this.urlParams.query_id as string;
}
// We derive the necessary props in the following cases
// 1. We load a queryId with no props
// 2. We load visible query props or empty
// 3. We are already loaded and are refetching data (for changed dates, e.g.)
let queryProps:string|undefined;
if (this.initializingWithQuery) {
// This is the case on initially loading the calendar with a query_id present in the url params but no
// query props to overwrite the query settings.
// We want to always use the currently displayed time interval to be filtered for
// so we need to adapt any eventually existing dateInterval filter to have that time interval. If no
// such filter exists yet, we need to add it to the existing filter set.
// In order to do both, we first need to fetch the query as we cannot signal
// to the backend yet to only add this one filter but leave the rest unchanged.
const initialQuery = await firstValueFrom(this.apiV3Service.queries.find({ pageSize: 0 }, queryId));
queryProps = this.generateQueryProps(
initialQuery,
startDate,
endDate,
);
} else if (this.initializingWithQueryProps) {
// This is the case on initially loading the calendar with query_props present in the url params.
// There might also be a query_id but the settings persisted in it are overwritten by the props.
if (this.urlParams.query_props) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const oldQueryProps:{ [key:string]:unknown } = JSON.parse(this.urlParams.query_props);
// Update the date period of the calendar in the filter
const newQueryProps = {
...oldQueryProps,
f: [
...(oldQueryProps.f as QueryPropsFilter[]).filter((filter:QueryPropsFilter) => filter.n !== 'datesInterval'),
OpWorkPackagesCalendarService.dateFilter(startDate, endDate),
],
pp: OpWorkPackagesCalendarService.MAX_DISPLAYED,
pa: 1,
};
queryProps = JSON.stringify(newQueryProps);
} else {
queryProps = OpWorkPackagesCalendarService.defaultQueryProps(startDate, endDate);
}
} else {
queryProps = this.generateQueryProps(
this.querySpace.query.value as QueryResource,
startDate,
endDate,
);
// There are no query props, ensure that they are not being shown the next load
this.wpListChecksumService.set(queryId, queryProps);
}
return Promise.all([this
.wpListService
.fromQueryParams({ query_id: queryId, query_props: queryProps, }, projectIdentifier || undefined)
.toPromise(),
])
}
public generateQueryProps(
query:QueryResource,
startDate:string,
endDate:string,
):string {
return this.urlParamsHelper.encodeQueryJsonParams(
query,
(props) => ({
...props,
pp: OpWorkPackagesCalendarService.MAX_DISPLAYED,
pa: 1,
f: [
...props.f.filter((filter) => filter.n !== 'datesInterval'),
OpWorkPackagesCalendarService.dateFilter(startDate, endDate),
],
}),
);
}
public get initialView():string|undefined {
return this.urlParams.cview as string|undefined;
}
dateEditable(wp:WorkPackageResource):boolean {
const schema = this.schemaCache.of(wp);
const schemaEditable = schema.isAttributeEditable('startDate') && schema.isAttributeEditable('dueDate');
return (wp.isLeaf || wp.scheduleManually) && schemaEditable;
}
eventDurationEditable(wp:WorkPackageResource):boolean {
return this.dateEditable(wp) && !this.isMilestone(wp);
}
/**
* The end date from fullcalendar is open, which means it targets
* the next day instead of current day 23:59:59.
* @param end A string representation of the end date
*/
public getEndDateFromTimestamp(end:string):string {
return moment(end).subtract(1, 'd').format('YYYY-MM-DD');
}
public openSplitView(id:string, onlyWhenOpen = false):void {
this.wpTableSelection.setSelection(id, -1);
// Only open the split view if already open, otherwise only clicking the details opens
if (onlyWhenOpen && !this.$state.includes('**.details.*')) {
return;
}
void this.$state.go(
`${splitViewRoute(this.$state)}.tabs`,
{ workPackageId: id, tabIdentifier: 'overview' },
);
}
public openFullView(id:string):void {
this.wpTableSelection.setSelection(id, -1);
void this.$state.go(
'work-packages.show',
{ workPackageId: id },
);
}
public onCardClicked({ workPackageId, event }:{ workPackageId:string, event:MouseEvent }):void {
if (isClickedWithModifier(event)) {
return;
}
this.openSplitView(workPackageId, true);
}
public onCardDblClicked({ workPackageId, event }:{ workPackageId:string, event:MouseEvent }):void {
if (isClickedWithModifier(event)) {
return;
}
this.openFullView(workPackageId);
}
public showEventContextMenu({ workPackageId, event }:{ workPackageId:string, event:MouseEvent }):void {
if (isClickedWithModifier(event)) {
return;
}
// We want to keep the original context menu on hrefs
// (currently, this is only the id)
if ((event.target as HTMLElement).closest(`.${uiStateLinkClass}`)) {
debugLog('Allowing original context menu on state link');
return;
}
// Set the selection to single
this.wpTableSelection.setSelection(workPackageId, -1);
event.preventDefault();
const handler = new WorkPackageViewContextMenu(this.injector, workPackageId, jQuery(event.target as HTMLElement));
this.contextMenuService.show(handler, event);
}
private defaultOptions():CalendarOptionsWithDayGrid {
return {
editable: false,
locales: allLocales,
locale: this.I18n.locale,
fixedWeekCount: false,
firstDay: this.configuration.startOfWeek(),
timeZone: this.configuration.isTimezoneSet() ? this.configuration.timezone() : 'local',
height: 'auto',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: '',
},
initialDate: this.initialDate,
initialView: this.initialView,
datesSet: (dates) => this.updateDateParam(dates),
dayHeaderClassNames: (data:DayHeaderContentArg) => this.calendarService.applyNonWorkingDay(data, this.nonWorkingDays),
dayCellClassNames: (data:DayCellContentArg) => this.calendarService.applyNonWorkingDay(data, this.nonWorkingDays),
dayGridClassNames: (data:DayCellContentArg) => this.calendarService.applyNonWorkingDay(data, this.nonWorkingDays),
slotLaneClassNames: (data:SlotLaneContentArg) => this.calendarService.applyNonWorkingDay(data, this.nonWorkingDays),
slotLabelClassNames: (data:SlotLabelContentArg) => this.calendarService.applyNonWorkingDay(data, this.nonWorkingDays),
};
}
private static defaultQueryProps(startDate:string, endDate:string) {
const props = {
c: ['id'],
t:
'id:asc',
f: [
{ n: 'status', o: '*', v: [] },
this.dateFilter(startDate, endDate),
],
dr: 'cards',
hi: false,
pp: OpWorkPackagesCalendarService.MAX_DISPLAYED,
pa: 1,
};
return JSON.stringify(props);
}
private static dateFilter(startDate:string, endDate:string):QueryPropsFilter {
return { n: 'datesInterval', o: '<>d', v: [startDate, endDate] };
}
private get initializingWithQueryProps():boolean {
// Initialise with current query props
// If the filters are empty, they still need to be initialised (with empty props)
return (this.areFiltersEmpty || this.urlParams.query_props) as boolean;
}
private get initializingWithQuery():boolean {
return this.areFiltersEmpty
&& !!this.urlParams.query_id
&& !this.urlParams.query_props;
}
public get urlParams() {
return this.uiRouterGlobals.params;
}
private get areFiltersEmpty():boolean {
return this.wpTableFilters.isEmpty;
}
private get initialDate():string|undefined {
const date = this.urlParams.cdate as string|undefined;
if (date) {
return this.timezoneService.formattedISODate(date);
}
return undefined;
}
private updateDateParam(dates:DatesSetArg) {
void this.$state.go(
'.',
{
cdate: this.timezoneService.formattedISODate(dates.view.calendar.getDate()),
// v6.beta3 fails to have type on the ViewAPI
cview: (dates.view as unknown as { type:string }).type,
},
{
custom: { notify: false },
},
);
}
updateDates(resizeInfo:EventResizeDoneArg|EventDropArg|EventReceiveArg, dragged?:boolean):ResourceChangeset<WorkPackageResource> {
const workPackage = resizeInfo.event.extendedProps.workPackage as WorkPackageResource;
const changeset = this.halEditing.edit(workPackage);
if (!workPackage.ignoreNonWorkingDays && workPackage.duration && dragged) {
changeset.setValue('duration', workPackage.duration);
} else {
const due = moment(resizeInfo.event.endStr)
.subtract(1, 'day')
.format('YYYY-MM-DD');
changeset.setValue('dueDate', due);
}
changeset.setValue('startDate', resizeInfo.event.startStr);
return changeset;
}
}