From 7f4988973cf17a65689bdb2f6e53904f6ffc4b50 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Fri, 13 Dec 2024 16:46:39 +0100 Subject: [PATCH] Add the new dialog the calendar widget --- .../core/path-helper/path-helper.service.ts | 4 + .../te-calendar/te-calendar.component.ts | 307 +++++++++++------- .../controllers/time_entries_controller.rb | 5 + 3 files changed, 201 insertions(+), 115 deletions(-) diff --git a/frontend/src/app/core/path-helper/path-helper.service.ts b/frontend/src/app/core/path-helper/path-helper.service.ts index 25bb5c7e8601..919c86e38c82 100644 --- a/frontend/src/app/core/path-helper/path-helper.service.ts +++ b/frontend/src/app/core/path-helper/path-helper.service.ts @@ -338,6 +338,10 @@ export class PathHelperService { return `${this.staticBase}/time_entries/work_packages/${workPackageId}/time_entry_activities`; } + public timeEntryDialog() { + return `${this.staticBase}/time_entries/dialog`; + } + public timeEntryWorkPackageDialog(workPackageId:string) { return `${this.workPackagePath(workPackageId)}/time_entries/dialog`; } diff --git a/frontend/src/app/features/calendar/te-calendar/te-calendar.component.ts b/frontend/src/app/features/calendar/te-calendar/te-calendar.component.ts index 6216191458f1..2e9020b352bb 100644 --- a/frontend/src/app/features/calendar/te-calendar/te-calendar.component.ts +++ b/frontend/src/app/features/calendar/te-calendar/te-calendar.component.ts @@ -34,9 +34,7 @@ import { ConfigurationService } from 'core-app/core/config/configuration.service import { TimeEntryResource } from 'core-app/features/hal/resources/time-entry-resource'; import { CollectionResource } from 'core-app/features/hal/resources/collection-resource'; import interactionPlugin from '@fullcalendar/interaction'; -import { - HalResourceEditingService, -} from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service'; +import { HalResourceEditingService } from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service'; import { TimeEntryEditService } from 'core-app/shared/components/time_entries/edit/edit.service'; import { TimeEntryCreateService } from 'core-app/shared/components/time_entries/create/create.service'; import { ColorsService } from 'core-app/shared/components/colors/colors.service'; @@ -51,12 +49,13 @@ import { OpCalendarService } from 'core-app/features/calendar/op-calendar.servic import { SchemaResource } from 'core-app/features/hal/resources/schema-resource'; import { IFieldSchema } from 'core-app/shared/components/fields/field.base'; import { VerboseFormattingArg } from '@fullcalendar/common'; -import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { firstValueFrom, Subject } from 'rxjs'; 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'; +import { TurboRequestsService } from 'core-app/core/turbo/turbo-requests.service'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; interface TimeEntrySchema extends SchemaResource { activity:IFieldSchema; @@ -83,7 +82,15 @@ interface CalendarOptionsWithDayGrid extends CalendarOptions { dayGridClassNames:(data:DayCellMountArg) => void; } // An array of all the days that are displayed. The zero index represents Monday. -export type DisplayedDays = [boolean, boolean, boolean, boolean, boolean, boolean, boolean]; +export type DisplayedDays = [ + boolean, + boolean, + boolean, + boolean, + boolean, + boolean, + boolean, +]; const TIME_ENTRY_CLASS_NAME = 'te-calendar--time-entry'; const DAY_SUM_CLASS_NAME = 'te-calendar--day-sum'; @@ -102,6 +109,8 @@ const ADD_ENTRY_PROHIBITED_CLASS_NAME = '-prohibited'; TimeEntryEditService, TimeEntryCreateService, HalResourceEditingService, + TurboRequestsService, + PathHelperService, ], }) export class TimeEntryCalendarComponent { @@ -126,7 +135,11 @@ export class TimeEntryCalendarComponent { public scaleRatio = 1; - protected memoizedTimeEntries:{ start:Moment, end:Moment, entries:Promise> }; + protected memoizedTimeEntries:{ + start:Moment; + end:Moment; + entries:Promise>; + }; public memoizedCreateAllowed = false; @@ -178,15 +191,14 @@ export class TimeEntryCalendarComponent { }; private initializeCalendar(displayedDayss:DisplayedDays) { - void this.weekdayService.loadWeekdays() + void this.weekdayService + .loadWeekdays() .toPromise() .then(async () => { const date = moment(new Date()).toString(); await this.requireNonWorkingDays(date); this.additionalOptions.hiddenDays = this.setHiddenDays(displayedDayss); - this.calendarOptions$.next( - this.additionalOptions, - ); + this.calendarOptions$.next(this.additionalOptions); }); } @@ -209,9 +221,11 @@ export class TimeEntryCalendarComponent { private calendar:OpCalendarService, readonly weekdayService:WeekdayService, readonly dayService:DayResourceService, - ) {} + readonly turboRequests:TurboRequestsService, + readonly pathHelper:PathHelperService, + ) { } - async requireNonWorkingDays(date:Date|string) { + async requireNonWorkingDays(date:Date | string) { this.nonWorkingDays = await firstValueFrom(this.dayService.requireNonWorkingYear$(date)); } @@ -219,7 +233,7 @@ export class TimeEntryCalendarComponent { fetchInfo:EventSourceFuncArg, successCallback:(events:EventInput[]) => void, failureCallback:(error:Error) => void, - ):void|PromiseLike { + ):void | PromiseLike { const start = moment(fetchInfo.startStr); const end = moment(fetchInfo.endStr); void this.fetchTimeEntries(start, end) @@ -231,21 +245,25 @@ export class TimeEntryCalendarComponent { .catch(failureCallback); } - protected fetchTimeEntries(start:Moment, end:Moment):Promise> { - if (!this.memoizedTimeEntries + protected fetchTimeEntries( + start:Moment, + end:Moment, + ):Promise> { + if ( + !this.memoizedTimeEntries || this.memoizedTimeEntries.start.valueOf() !== start.valueOf() - || this.memoizedTimeEntries.end.valueOf() !== end.valueOf()) { + || this.memoizedTimeEntries.end.valueOf() !== end.valueOf() + ) { const promise = firstValueFrom( - this - .apiV3Service - .time_entries - .list({ filters: this.dmFilters(start, end), pageSize: 500 }), - ) - .then((collection) => { - this.memoizedCreateAllowed = !!collection.createTimeEntry; - - return collection; - }); + this.apiV3Service.time_entries.list({ + filters: this.dmFilters(start, end), + pageSize: 500, + }), + ).then((collection) => { + this.memoizedCreateAllowed = !!collection.createTimeEntry; + + return collection; + }); this.memoizedTimeEntries = { start, end, entries: promise }; } @@ -253,12 +271,16 @@ export class TimeEntryCalendarComponent { return this.memoizedTimeEntries.entries; } - private async buildEntries(entries:TimeEntryResource[], fetchInfo:{ start:Date, end:Date }):Promise { + private async buildEntries( + entries:TimeEntryResource[], + fetchInfo:{ start:Date; end:Date }, + ):Promise { this.setRatio(entries); await this.requireNonWorkingDays(fetchInfo.start); await this.requireNonWorkingDays(fetchInfo.end); - return this.buildTimeEntryEntries(entries) - .concat(this.buildAuxEntries(entries, fetchInfo)); + return this.buildTimeEntryEntries(entries).concat( + this.buildAuxEntries(entries, fetchInfo), + ); } private setRatio(entries:TimeEntryResource[]):void { @@ -269,7 +291,9 @@ export class TimeEntryCalendarComponent { const oldRatio = this.scaleRatio; if (maxHours > this.maxHour - this.minHour) { - this.scaleRatio = this.smallerSuitableRatio((this.maxHour - this.minHour) / maxHours); + this.scaleRatio = this.smallerSuitableRatio( + (this.maxHour - this.minHour) / maxHours, + ); } else { this.scaleRatio = 1; } @@ -279,10 +303,12 @@ export class TimeEntryCalendarComponent { // We already set the same function (different object) via angular. // But it will trigger repainting the calendar. // Weirdly, this.ucCalendar.getApi().rerender() does not. - this.ucCalendar.getApi().setOption('slotLabelFormat', (info:VerboseFormattingArg) => { - const val = (this.maxHour - info.date.hour) / this.scaleRatio; - return val.toString(); - }); + this.ucCalendar + .getApi() + .setOption('slotLabelFormat', (info:VerboseFormattingArg) => { + const val = (this.maxHour - info.date.hour) / this.scaleRatio; + return val.toString(); + }); } } @@ -293,7 +319,7 @@ export class TimeEntryCalendarComponent { let start:Moment; let end:Moment; const hours = this.timezone.toHours(entry.hours) * this.scaleRatio; - const spentOn = entry.spentOn as string; + const spentOn = entry.spentOn; if (hoursDistribution[spentOn]) { start = hoursDistribution[spentOn].clone().subtract(hours, 'h'); @@ -309,12 +335,19 @@ export class TimeEntryCalendarComponent { }); } - private buildAuxEntries(entries:TimeEntryResource[], fetchInfo:{ start:Date, end:Date }):EventInput[] { + private buildAuxEntries( + entries:TimeEntryResource[], + fetchInfo:{ start:Date; end:Date }, + ):EventInput[] { const dateSums = this.calculateDateSums(entries); const calendarEntries:EventInput[] = []; - for (let m = moment(this.timezone.formattedISODate(fetchInfo.start)); m.diff(fetchInfo.end, 'days') <= 0; m.add(1, 'days')) { + for ( + let m = moment(this.timezone.formattedISODate(fetchInfo.start)); + m.diff(fetchInfo.end, 'days') <= 0; + m.add(1, 'days') + ) { const duration = dateSums[m.format('YYYY-MM-DD')] || 0; calendarEntries.push(this.sumEntry(m, duration)); @@ -327,12 +360,14 @@ export class TimeEntryCalendarComponent { return calendarEntries; } - private calculateDateSums(entries:TimeEntryResource[]):{ [p:string]:number } { + private calculateDateSums(entries:TimeEntryResource[]):{ + [p:string]:number; + } { const dateSums:{ [key:string]:number } = {}; entries.forEach((entry) => { const hours = this.timezone.toHours(entry.hours); - const spentOn = entry.spentOn as string; + const spentOn = entry.spentOn; if (dateSums[spentOn]) { dateSums[spentOn] += hours; @@ -344,7 +379,12 @@ export class TimeEntryCalendarComponent { return dateSums; } - protected timeEntry(entry:TimeEntryResource, hours:number, start:Moment, end:Moment):EventInput { + protected timeEntry( + entry:TimeEntryResource, + hours:number, + start:Moment, + end:Moment, + ):EventInput { const color = this.colors.toHsl(this.entryName(entry)); const classNames = [TIME_ENTRY_CLASS_NAME]; @@ -369,8 +409,23 @@ export class TimeEntryCalendarComponent { protected sumEntry(date:Moment, duration:number):EventInput { return { - start: date.clone().add(this.maxHour - Math.min(duration * this.scaleRatio, this.maxHour - 0.5) - 0.5, 'h').format(), - end: date.clone().add(this.maxHour - Math.min(((duration + 0.05) * this.scaleRatio), this.maxHour - 0.5), 'h').format(), + start: date + .clone() + .add( + this.maxHour + - Math.min(duration * this.scaleRatio, this.maxHour - 0.5) + - 0.5, + 'h', + ) + .format(), + end: date + .clone() + .add( + this.maxHour + - Math.min((duration + 0.05) * this.scaleRatio, this.maxHour - 0.5), + 'h', + ) + .format(), classNames: DAY_SUM_CLASS_NAME, rendering: 'background' as const, startEditable: false, @@ -388,37 +443,52 @@ export class TimeEntryCalendarComponent { return { start: date.clone().format(), - end: date.clone().add(this.maxHour - Math.min(duration * this.scaleRatio, this.maxHour - 1) - 0.5, 'h').format(), + end: date + .clone() + .add( + this.maxHour + - Math.min(duration * this.scaleRatio, this.maxHour - 1) + - 0.5, + 'h', + ) + .format(), rendering: 'background' as const, classNames, }; } - protected dmFilters(start:Moment, end:Moment):Array<[string, FilterOperator, string[]]> { + protected dmFilters( + start:Moment, + end:Moment, + ):Array<[string, FilterOperator, string[]]> { const startDate = start.format('YYYY-MM-DD'); const endDate = end.subtract(1, 'd').format('YYYY-MM-DD'); - return [['spentOn', '<>d', [startDate, endDate]] as [string, FilterOperator, string[]], - ['user_id', '=', ['me']] as [string, FilterOperator, [string]]]; + return [ + ['spentOn', '<>d', [startDate, endDate]] as [ + string, + FilterOperator, + string[], + ], + ['user_id', '=', ['me']] as [string, FilterOperator, [string]], + ]; } private dispatchEventClick(event:CalendarViewEvent):void { if (event.event.extendedProps.entry) { this.editEvent(event.event.extendedProps.entry); - } else if (event.el.classList.contains(ADD_ENTRY_CLASS_NAME) && !event.el.classList.contains(ADD_ENTRY_PROHIBITED_CLASS_NAME)) { + } else if ( + event.el.classList.contains(ADD_ENTRY_CLASS_NAME) + && !event.el.classList.contains(ADD_ENTRY_PROHIBITED_CLASS_NAME) + ) { this.addEvent(moment(event.event.startStr)); } } private editEvent(entry:TimeEntryResource):void { - this - .timeEntryEdit - .edit(entry, { showUserField: false }) - .then((modificationAction) => { - this.updateEventSet(modificationAction.entry, modificationAction.action); - }) - .catch(() => { - // do nothing, the user closed without changes - }); + void this.turboRequests.request( + `${this.pathHelper.timeEntryDialog()}?time_entry_id=${entry.id}`, + { method: 'GET' }, + ); } private moveEvent(event:CalendarMoveEvent):void { @@ -428,23 +498,18 @@ export class TimeEntryCalendarComponent { // on the day before by fullcalendar. entry.spentOn = moment(event.event.endStr).format('YYYY-MM-DD'); - void this - .schemaCache - .ensureLoaded(entry) - .then((schema) => { - this - .apiV3Service - .time_entries - .id(entry) - .patch(entry, schema) - .subscribe( - (updated) => this.updateEventSet(updated, 'update'), - (e) => { - this.notifications.handleRawError(e); - event.revert(); - }, - ); - }); + void this.schemaCache.ensureLoaded(entry).then((schema) => { + this.apiV3Service.time_entries + .id(entry) + .patch(entry, schema) + .subscribe( + (updated) => this.updateEventSet(updated, 'update'), + (e) => { + this.notifications.handleRawError(e); + event.revert(); + }, + ); + }); } public addEventToday():void { @@ -456,20 +521,20 @@ export class TimeEntryCalendarComponent { return; } - this - .timeEntryCreate - .create(date, undefined, { showUserField: false }) - .then((modificationAction) => { - this.updateEventSet(modificationAction.entry, modificationAction.action); - }) - .catch(() => { - // do nothing, the user closed without changes - }); + void this.turboRequests.request( + `${this.pathHelper.timeEntryDialog()}?date=${date.format('YYYY-MM-DD')}`, + { method: 'GET' }, + ); } - private updateEventSet(event:TimeEntryResource, action:'update'|'destroy'|'create'|'unchanged'):void { + private updateEventSet( + event:TimeEntryResource, + action:'update' | 'destroy' | 'create' | 'unchanged', + ):void { void this.memoizedTimeEntries.entries.then((collection) => { - const foundIndex = collection.elements.findIndex((x) => x.id === event.id); + const foundIndex = collection.elements.findIndex( + (x) => x.id === event.id, + ); switch (action) { case 'update': @@ -480,11 +545,7 @@ export class TimeEntryCalendarComponent { collection.elements.splice(foundIndex, 1); break; case 'create': - void this - .apiV3Service - .time_entries - .cache - .updateFor(event); + void this.apiV3Service.time_entries.cache.updateFor(event); collection.elements.push(event); break; @@ -533,10 +594,15 @@ export class TimeEntryCalendarComponent { const { entry } = event.event.extendedProps; - const schema = await this.schemaCache.ensureLoaded(entry as TimeEntryResource) as TimeEntrySchema; + const schema = (await this.schemaCache.ensureLoaded( + entry as TimeEntryResource, + )) as TimeEntrySchema; jQuery(event.el).tooltip({ - content: this.tooltipContentString(event.event.extendedProps.entry, schema), + content: this.tooltipContentString( + event.event.extendedProps.entry, + schema, + ), items: '.fc-event', close() { jQuery('.ui-helper-hidden-accessible').remove(); @@ -568,13 +634,13 @@ export class TimeEntryCalendarComponent { } /* Fade out event text to the bottom to avoid it being cut of weirdly. - * Multiline ellipsis with an unknown height is not possible, hence we blur the text. - * The gradient needs to take the background color of the element into account (hashed over the event - * title) which is why the style is set in code. - * - * We do not print anything on short entries (< 0.5 hours), - * which leads to the fc-short class not being applied by full calendar. For other short events, the css rules - * need to deactivate the fc-fadeout. + * Multiline ellipsis with an unknown height is not possible, hence we blur the text. + * The gradient needs to take the background color of the element into account (hashed over the event + * title) which is why the style is set in code. + * + * We do not print anything on short entries (< 0.5 hours), + * which leads to the fc-short class not being applied by full calendar. For other short events, the css rules + * need to deactivate the fc-fadeout. */ private appendFadeout(event:CalendarViewEvent):void { const timeEntry = event.event.extendedProps.entry as TimeEntryResource; @@ -589,14 +655,24 @@ export class TimeEntryCalendarComponent { const hslaStart = this.colors.toHsla(this.entryName(timeEntry), 0); const hslaEnd = this.colors.toHsla(this.entryName(timeEntry), 100); - fadeout.css('background', `-webkit-linear-gradient(${hslaStart} 0%, ${hslaEnd} 100%`); - - ['-moz-linear-gradient', '-o-linear-gradient', 'linear-gradient', '-ms-linear-gradient'].forEach(((style) => { - fadeout.css('background-image', `${style}(${hslaStart} 0%, ${hslaEnd} 100%`); - })); + fadeout.css( + 'background', + `-webkit-linear-gradient(${hslaStart} 0%, ${hslaEnd} 100%`, + ); + + [ + '-moz-linear-gradient', + '-o-linear-gradient', + 'linear-gradient', + '-ms-linear-gradient', + ].forEach((style) => { + fadeout.css( + 'background-image', + `${style}(${hslaStart} 0%, ${hslaEnd} 100%`, + ); + }); - $element - .append(fadeout); + $element.append(fadeout); } private beforeEventRemove(event:CalendarViewEvent):void { @@ -617,11 +693,14 @@ export class TimeEntryCalendarComponent { } private workPackageName(entry:TimeEntryResource):string { - const workPackage = entry.workPackage as WorkPackageResource; + const workPackage = entry.workPackage; return `#${idFromLink(workPackage.href)}: ${workPackage.name}`; } - private tooltipContentString(entry:TimeEntryResource, schema:TimeEntrySchema):string { + private tooltipContentString( + entry:TimeEntryResource, + schema:TimeEntrySchema, + ):string { return `
  • @@ -668,13 +747,11 @@ export class TimeEntryCalendarComponent { } protected setHiddenDays(displayedDays:DisplayedDays) { - return Array - .from(displayedDays, (value, index) => { - if (!value) { - return (index + 1) % 7; - } - return null; - }) - .filter((value) => value !== null) as number[]; + return Array.from(displayedDays, (value, index) => { + if (!value) { + return (index + 1) % 7; + } + return null; + }).filter((value) => value !== null) as number[]; } } diff --git a/modules/costs/app/controllers/time_entries_controller.rb b/modules/costs/app/controllers/time_entries_controller.rb index 14092dd3d45f..36e539c0b441 100644 --- a/modules/costs/app/controllers/time_entries_controller.rb +++ b/modules/costs/app/controllers/time_entries_controller.rb @@ -43,12 +43,17 @@ def dialog end end @work_package = WorkPackage.visible.find_by(id: params[:work_package_id]) if params[:work_package_id].present? + @time_entry = if params[:time_entry_id] # TODO: Properly handle authorization TimeEntry.find_by(id: params[:time_entry_id]) else TimeEntry.new(project: @project, work_package: @work_package, user: User.current) end + + if params[:date].present? + @time_entry.spent_on = params[:date] + end end def user_tz_caption