diff --git a/frontend/src/app/shared/components/datepicker/datepicker.module.ts b/frontend/src/app/shared/components/datepicker/datepicker.module.ts index aa7f871935c2..5c422abc728d 100644 --- a/frontend/src/app/shared/components/datepicker/datepicker.module.ts +++ b/frontend/src/app/shared/components/datepicker/datepicker.module.ts @@ -8,7 +8,6 @@ import { CommonModule } from '@angular/common'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { OpModalSingleDatePickerComponent } from './modal-single-date-picker/modal-single-date-picker.component'; -import { OpWpMultiDateFormComponent } from './wp-multi-date-form/wp-multi-date-form.component'; import { OpWpSingleDateFormComponent } from './wp-single-date-form/wp-single-date-form.component'; import { OpBasicDatePickerModule } from './basic-datepicker.module'; import { OpSpotModule } from 'core-app/spot/spot.module'; @@ -35,7 +34,6 @@ import { OpWpModalDatePickerComponent } from 'core-app/shared/components/datepic declarations: [ OpModalSingleDatePickerComponent, - OpWpMultiDateFormComponent, OpWpSingleDateFormComponent, OpDatePickerSheetComponent, OpWpModalDatePickerComponent, @@ -43,7 +41,6 @@ import { OpWpModalDatePickerComponent } from 'core-app/shared/components/datepic exports: [ OpModalSingleDatePickerComponent, - OpWpMultiDateFormComponent, OpWpSingleDateFormComponent, OpBasicDatePickerModule, OpDatePickerSheetComponent, diff --git a/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.html b/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.html deleted file mode 100644 index e0e44644f185..000000000000 --- a/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.html +++ /dev/null @@ -1,138 +0,0 @@ -
- - - - - - - - - - - - - - -
- - - - - - - - - - - -
- - - -
-
- - -
-
-
diff --git a/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.ts b/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.ts deleted file mode 100644 index 357015d8c6d8..000000000000 --- a/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.ts +++ /dev/null @@ -1,843 +0,0 @@ -//-- copyright -// OpenProject is an open source project management software. -// Copyright (C) the OpenProject GmbH -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License version 3. -// -// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -// Copyright (C) 2006-2013 Jean-Philippe Lang -// Copyright (C) 2010-2013 the ChiliProject Team -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License -// as published by the Free Software Foundation; either version 2 -// of the License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -// -// See COPYRIGHT and LICENSE files for more details. -//++ - -import { - AfterViewInit, - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - ElementRef, - EventEmitter, - Input, - Injector, - ViewChild, - ViewEncapsulation, - OnInit, - Output, - HostBinding, -} from '@angular/core'; -import { I18nService } from 'core-app/core/i18n/i18n.service'; -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 { TimezoneService } from 'core-app/core/datetime/timezone.service'; -import { DayElement } from 'flatpickr/dist/types/instance'; -import flatpickr from 'flatpickr'; -import { - debounce, - debounceTime, - filter, - map, - switchMap, -} from 'rxjs/operators'; -import { - fromEvent, - merge, - Observable, - Subject, - timer, -} from 'rxjs'; -import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; -import { FormResource } from 'core-app/features/hal/resources/form-resource'; -import { DateModalRelationsService } from 'core-app/shared/components/datepicker/services/date-modal-relations.service'; -import { - areDatesEqual, - mappedDate, - onDayCreate, - parseDate, - setDates, - validDate, -} from 'core-app/shared/components/datepicker/helpers/date-modal.helpers'; -import { WeekdayService } from 'core-app/core/days/weekday.service'; -import { FocusHelperService } from 'core-app/shared/directives/focus/focus-helper'; -import { DeviceService } from 'core-app/core/browser/device.service'; -import { DatePicker } from '../datepicker'; - -import DateOption = flatpickr.Options.DateOption; -import { WorkPackageChangeset } from 'core-app/features/work-packages/components/wp-edit/work-package-changeset'; -import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; -import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; -import { DateModalSchedulingService } from '../services/date-modal-scheduling.service'; -import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; - -export type DateKeys = 'start'|'end'; -export type DateFields = DateKeys|'duration'; - -export type StartUpdate = { startDate:string }; -export type EndUpdate = { dueDate:string }; -export type DurationUpdate = { duration:string|number|null }; -export type DateUpdate = { date:string }; -export type ActiveDateChange = [DateFields, null|Date|Date]; - -export type FieldUpdates = - StartUpdate - |EndUpdate - |(StartUpdate&EndUpdate) - |(StartUpdate&DurationUpdate) - |(EndUpdate&DurationUpdate) - |DateUpdate; - -@Component({ - selector: 'op-wp-multi-date-form', - templateUrl: './wp-multi-date-form.component.html', - styleUrls: [ - '../styles/datepicker.modal.sass', - ], - changeDetection: ChangeDetectionStrategy.OnPush, - encapsulation: ViewEncapsulation.None, - providers: [ - DateModalRelationsService, - DateModalSchedulingService, - ], -}) -export class OpWpMultiDateFormComponent extends UntilDestroyedMixin implements AfterViewInit, OnInit { - @HostBinding('class.op-datepicker-modal') className = true; - - @HostBinding('class.op-datepicker-modal_wide') classNameWide = true; - - @ViewChild('modalContainer') modalContainer:ElementRef; - - @ViewChild('durationField', { read: ElementRef }) durationField:ElementRef; - - @ViewChild('flatpickrTarget') flatpickrTarget:ElementRef; - - @Input() changeset:ResourceChangeset; - - @Input() fieldName:string = ''; - - @Output() cancel = new EventEmitter(); - - @Output() save = new EventEmitter(); - - public turboFrameSrc:string; - - text = { - save: this.I18n.t('js.button_save'), - cancel: this.I18n.t('js.button_cancel'), - startDate: this.I18n.t('js.work_packages.properties.startDate'), - endDate: this.I18n.t('js.work_packages.properties.dueDate'), - duration: this.I18n.t('js.work_packages.properties.duration'), - placeholder: this.I18n.t('js.placeholders.default'), - today: this.I18n.t('js.label_today'), - days: (count:number):string => this.I18n.t('js.units.day', { count }), - }; - - scheduleManually = false; - - ignoreNonWorkingDays = false; - - duration:number|null; - - currentlyActivatedDateField:DateFields; - - htmlId = ''; - - dates:{ [key in DateKeys]:string|null } = { - start: null, - end: null, - }; - - // Manual changes from the inputs to start and end dates - startDateChanged$ = new Subject(); - - startDateDebounced$:Observable = this.debouncedInput(this.startDateChanged$, 'start'); - - endDateChanged$ = new Subject(); - - endDateDebounced$:Observable = this.debouncedInput(this.endDateChanged$, 'end'); - - // Manual changes to the datepicker, with information which field was active - datepickerChanged$ = new Subject(); - - // We want to position the modal as soon as the datepicker gets initialized - // But if we destroy and recreate the datepicker (e.g., when toggling switches), keep current position - modalPositioned = false; - - // Date updates from the datepicker or a manual change - dateUpdates$ = merge( - this.startDateDebounced$, - this.endDateDebounced$, - this.datepickerChanged$, - ) - .pipe( - this.untilDestroyed(), - filter(() => !!this.datePickerInstance), - ) - .subscribe(([field, update]) => { - // When clearing the one date, clear the others as well - if (update !== null) { - this.handleSingleDateUpdate(field, update); - } - - // Clear active field and duration - // when the active field was cleared - if (update === null && field !== 'duration') { - this.clearWithDuration(field); - } - - // The duration field is special in how it handles focus transitions - // For start/due we just toggle here - if (update !== null && field !== 'duration') { - this.toggleCurrentActivatedField(); - } - - this.cdRef.detectChanges(); - }); - - // Duration changes - durationChanges$ = new Subject(); - - durationDebounced$ = this - .durationChanges$ - .pipe( - this.untilDestroyed(), - debounce((value) => (value ? timer(500) : timer(0))), - map((value) => (value === '' ? null : Math.abs(parseInt(value, 10)))), - filter((val) => val === null || !Number.isNaN(val)), - filter((val) => val !== this.duration), - ) - .subscribe((value) => this.applyDurationChange(value)); - - // Duration is a special field as it changes its value based on its focus state - // which is different from the highlight state... - durationFocused = false; - - ignoreNonWorkingDaysWritable = true; - - private datePickerInstance:DatePicker; - - private formUpdates$ = new Subject(); - - private minimalSchedulingDate:Date|null = null; - - constructor( - readonly injector:Injector, - readonly cdRef:ChangeDetectorRef, - readonly apiV3Service:ApiV3Service, - readonly I18n:I18nService, - readonly timezoneService:TimezoneService, - readonly halEditing:HalResourceEditingService, - readonly dateModalScheduling:DateModalSchedulingService, - readonly dateModalRelations:DateModalRelationsService, - readonly deviceService:DeviceService, - readonly weekdayService:WeekdayService, - readonly focusHelper:FocusHelperService, - readonly pathHelper:PathHelperService, - ) { - super(); - - this - .formUpdates$ - .pipe( - this.untilDestroyed(), - switchMap((fieldsToUpdate:FieldUpdates) => this - .apiV3Service - .work_packages - .withOptionalId(this.changeset.id === 'new' ? null : this.changeset.id) - .form - .forPayload({ - ...fieldsToUpdate, - lockVersion: this.changeset.value('lockVersion'), - ignoreNonWorkingDays: this.ignoreNonWorkingDays, - scheduleManually: this.scheduleManually, - })), - ) - .subscribe((form) => this.updateDatesFromForm(form)); - } - - ngOnInit(): void { - this.htmlId = `wp-datepicker-${this.fieldName as string}`; - - this.dateModalScheduling.setChangeset(this.changeset as WorkPackageChangeset); - this.dateModalRelations.setChangeset(this.changeset as WorkPackageChangeset); - - this.scheduleManually = !!this.changeset.value('scheduleManually'); - this.ignoreNonWorkingDays = !!this.changeset.value('ignoreNonWorkingDays'); - - // Ensure we get the writable values from the loaded form - void this - .changeset - .getForm() - .then((form) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - this.ignoreNonWorkingDaysWritable = !!form.schema.ignoreNonWorkingDays.writable; - this.cdRef.detectChanges(); - }); - - this.setDurationDaysFromUpstream(this.changeset.value('duration')); - - this.dates.start = this.changeset.value('startDate'); - this.dates.end = this.changeset.value('dueDate'); - this.setCurrentActivatedField(this.initialActivatedField); - - this.turboFrameSrc = this.pathHelper.workPackageDatepickerDialogContentPath(this.changeset.id); - } - - ngAfterViewInit():void { - const init = () => { - this.initializeDatepicker(); - - // Autofocus duration if that's what activated us - if (this.initialActivatedField === 'duration') { - this.focusHelper.focus(this.durationField.nativeElement); - } - }; - - if (isNewResource(this.changeset.pristineResource)) { - init(); - return; - } - - this - .dateModalRelations - .getMinimalDateFromPreceeding() - .subscribe((date) => { - this.minimalSchedulingDate = date; - init(); - }); - } - - changeSchedulingMode():void { - // If removing manual scheduling on parent, reset ignoreNWD to original value - if (this.scheduleManually === false && !this.ignoreNonWorkingDaysWritable) { - this.ignoreNonWorkingDays = !!this.changeset.value('ignoreNonWorkingDays'); - } - - this.datePickerInstance?.datepickerInstance.redraw(); - this.cdRef.detectChanges(); - } - - changeNonWorkingDays():void { - this.datePickerInstance?.datepickerInstance.redraw(); - - // Resent the current start and duration so that the end date is calculated - if (!!this.dates.start && !!this.duration) { - this.formUpdates$.next({ startDate: this.dates.start, duration: this.durationAsIso8601 }); - } - - // If only one of the dates is set, sent that - // Resent the current start and duration so that the end date is calculated - if (!!this.dates.start && !this.dates.end) { - this.formUpdates$.next({ startDate: this.dates.start }); - } - - if (!!this.dates.end && !this.dates.start) { - this.formUpdates$.next({ dueDate: this.dates.end }); - } - - this.cdRef.detectChanges(); - } - - doSave($event:Event):void { - $event.preventDefault(); - // Apply the changed scheduling mode if any - this.changeset.setValue('scheduleManually', this.scheduleManually); - - // Apply include NWD - this.changeset.setValue('ignoreNonWorkingDays', this.ignoreNonWorkingDays); - - // Apply the dates if they could be changed - if (this.isSchedulable) { - this.changeset.setValue('startDate', mappedDate(this.dates.start)); - this.changeset.setValue('dueDate', mappedDate(this.dates.end)); - this.changeset.setValue('duration', this.durationAsIso8601); - } - - this.save.emit(); - } - - doCancel():void { - this.cancel.emit(); - } - - updateDate(key:DateKeys, val:string|null):void { - if ((val === null || validDate(val)) && this.datePickerInstance) { - this.dates[key] = mappedDate(val); - const dateValue = parseDate(val || '') || undefined; - this.enforceManualChangesToDatepicker(dateValue); - this.cdRef.detectChanges(); - } - } - - setCurrentActivatedField(val:DateFields):void { - this.currentlyActivatedDateField = val; - } - - toggleCurrentActivatedField():void { - this.currentlyActivatedDateField = this.currentlyActivatedDateField === 'start' ? 'end' : 'start'; - } - - isStateOfCurrentActivatedField(val:DateFields):boolean { - return this.currentlyActivatedDateField === val; - } - - setToday(key:DateKeys):void { - this.datepickerChanged$.next([key, new Date()]); - } - - showTodayLink():boolean { - return this.isSchedulable; - } - - /** - * Returns whether the user can alter the dates of the work package. - */ - get isSchedulable():boolean { - return this.scheduleManually || !this.dateModalRelations.isParent; - } - - showFieldAsActive(field:DateFields):boolean { - return this.isStateOfCurrentActivatedField(field) && this.isSchedulable; - } - - handleDurationFocusIn():void { - this.durationFocused = true; - this.setCurrentActivatedField('duration'); - } - - handleDurationFocusOut():void { - setTimeout(() => { - this.durationFocused = false; - this.cdRef.detectChanges(); - }); - } - - get displayedDuration():string { - if (!this.duration) { - return ''; - } - - return this.text.days(this.duration); - } - - private applyDurationChange(newValue:number|null):void { - this.duration = newValue; - this.cdRef.detectChanges(); - - // If we cleared duration or left it empty - // reset the value and the due date - if (newValue === null) { - this.updateDate('end', null); - return; - } - - if (this.dates.start) { - this.formUpdates$.next({ - startDate: this.dates.start, - duration: this.durationAsIso8601, - }); - } else if (this.dates.end) { - this.formUpdates$.next({ - dueDate: this.dates.end, - duration: this.durationAsIso8601, - }); - } - } - - private get durationAsIso8601():string|null { - if (this.duration) { - return this.timezoneService.toISODuration(this.duration, 'days'); - } - - return null; - } - - private clearWithDuration(field:DateKeys) { - this.duration = null; - this.dates[field] = null; - this.enforceManualChangesToDatepicker(); - } - - private initializeDatepicker() { - this.datePickerInstance?.destroy(); - this.datePickerInstance = new DatePicker( - this.injector, - '#flatpickr-input', - [this.dates.start || '', this.dates.end || ''], - { - mode: 'range', - showMonths: this.deviceService.isMobile ? 1 : 2, - inline: true, - onReady: (_date, _datestr, instance) => { - instance.calendarContainer.classList.add('op-datepicker-modal--flatpickr-instance'); - - if (!this.modalPositioned) { - this.modalPositioned = true; - } - - this.ensureHoveredSelection(instance.calendarContainer); - }, - onChange: (dates:Date[], _datestr, instance) => { - const activeField = this.currentlyActivatedDateField; - - // When two values are passed from datepicker and we don't have duration set, - // just take the range provided by them - if (dates.length === 2 && !this.duration) { - this.setDatesAndDeriveDuration(dates[0], dates[1]); - this.toggleCurrentActivatedField(); - return; - } - - // Update with the same flow as entering a value - const { latestSelectedDateObj } = instance as { latestSelectedDateObj:Date }; - this.datepickerChanged$.next([activeField, latestSelectedDateObj]); - }, - // eslint-disable-next-line @typescript-eslint/no-misused-promises - onDayCreate: async (dObj:Date[], dStr:string, fp:flatpickr.Instance, dayElem:DayElement) => { - onDayCreate( - dayElem, - this.ignoreNonWorkingDays, - await this.datePickerInstance?.isNonWorkingDay(dayElem.dateObj), - this.isDayDisabled(dayElem, this.minimalSchedulingDate), - ); - }, - }, - this.flatpickrTarget.nativeElement, - ); - } - - private enforceManualChangesToDatepicker(enforceDate?:Date) { - let startDate = parseDate(this.dates.start || ''); - let endDate = parseDate(this.dates.end || ''); - - if (startDate && endDate) { - // If the start date is manually changed to be after the end date, - // we adjust the end date to be at least the same as the newly entered start date. - // Same applies if the end date is set manually before the current start date - if (startDate > endDate && this.isStateOfCurrentActivatedField('start')) { - endDate = startDate; - this.dates.end = this.timezoneService.formattedISODate(endDate); - } else if (endDate < startDate && this.isStateOfCurrentActivatedField('end')) { - startDate = endDate; - this.dates.start = this.timezoneService.formattedISODate(startDate); - } - } - - const dates = [startDate, endDate]; - setDates(dates, this.datePickerInstance, enforceDate); - } - - private setDatesAndDeriveDuration(newStart:Date, newEnd:Date) { - this.dates.start = this.timezoneService.formattedISODate(newStart); - this.dates.end = this.timezoneService.formattedISODate(newEnd); - - // Derive duration - this.formUpdates$.next({ startDate: this.dates.start, dueDate: this.dates.end }); - } - - private handleSingleDateUpdate(activeField:DateFields, selectedDate:Date) { - if (activeField === 'duration') { - this.durationActiveDateSelected(selectedDate); - return; - } - - // If both dates are now set, ensure we update it accordingly - if (this.dates.start && this.dates.end) { - this.replaceDatesWithNewSelection(activeField, selectedDate); - return; - } - - // Set the current date field - this.moveActiveDate(activeField, selectedDate); - - // We may or may not have both fields set now - // If we have duration set, we derive the other field - if (this.duration) { - this.deriveMissingDateFromDuration(activeField); - } else if (this.dates.start && this.dates.end) { - this.formUpdates$.next({ startDate: this.dates.start, dueDate: this.dates.end }); - } - - // Set the selected date on the datepicker - this.enforceManualChangesToDatepicker(selectedDate); - } - - /** - * The duration field is active and a date was clicked in the datepicker. - * - * If the duration field has a value: - * - start date is updated, derive end date, set end date active - * If the duration field has no value: - * - If start date has a value, finish date is set - * - Otherwise, start date is set - * - Focus is set to the finish date - * - * @param selectedDate The date selected - * @private - */ - private durationActiveDateSelected(selectedDate:Date) { - const selectedIsoDate = this.timezoneService.formattedISODate(selectedDate); - - if (!this.duration && this.dates.start) { - // When duration is empty and start is set, update finish - this.setDaysInOrder(this.dates.start, selectedIsoDate); - - // Focus moves to start date - this.setCurrentActivatedField('start'); - } else { - // Otherwise, the start date always gets updated - this.setDaysInOrder(selectedIsoDate, this.dates.end); - - // Focus moves to finish date - this.setCurrentActivatedField('end'); - } - - if (this.dates.start && this.duration) { - // If duration has value, derive end date from start and duration - this.formUpdates$.next({ startDate: this.dates.start, duration: this.durationAsIso8601 }); - } else if (this.dates.start && this.dates.end) { - // If start and due now have values, derive duration again - this.formUpdates$.next({ startDate: this.dates.start, dueDate: this.dates.end }); - } - } - - private setDaysInOrder(start:string|null, end:string|null) { - const parsedStartDate = start ? parseDate(start) as Date : null; - const parsedEndDate = end ? parseDate(end) as Date : null; - - if (parsedStartDate && parsedEndDate && parsedStartDate > parsedEndDate) { - this.dates.start = end; - this.dates.end = start; - } else { - this.dates.start = start; - this.dates.end = end; - } - } - - /** - * The active field was updated in the datepicker, while the other date was not set - * - * This means we want to derive the non-active field using the duration, if that is set. - * - * @param activeField The active field that was changed - * @private - */ - private deriveMissingDateFromDuration(activeField:'start'|'end') { - if (activeField === 'start' && !!this.dates.start) { - this.formUpdates$.next({ startDate: this.dates.start, duration: this.durationAsIso8601 }); - } - - if (activeField === 'end' && !!this.dates.end) { - this.formUpdates$.next({ dueDate: this.dates.end, duration: this.durationAsIso8601 }); - } - } - - /** - * Moves the active date to the given selected date. - * - * This is different from replaceDatesWithNewSelection as duration is prioritized higher in our case. - * @param activeField - * @param selectedDate - * @private - */ - private moveActiveDate(activeField:DateKeys, selectedDate:Date) { - const parsedStartDate = this.dates.start ? parseDate(this.dates.start) as Date : null; - const parsedEndDate = this.dates.end ? parseDate(this.dates.end) as Date : null; - - // Set the given field - this.dates[activeField] = this.timezoneService.formattedISODate(selectedDate); - - // Special handling, moving finish date to before start date - if (activeField === 'end' && parsedStartDate && parsedStartDate > selectedDate) { - // Reset duration and start date - this.duration = null; - this.dates.start = null; - // Update finish date and mark as active in datepicker - this.enforceManualChangesToDatepicker(selectedDate); - } - - // Special handling, moving start date to after finish date - if (activeField === 'start' && parsedEndDate && parsedEndDate < selectedDate) { - // Reset duration and start date - this.duration = null; - this.dates.end = null; - // Update finish date and mark as active in datepicker - this.enforceManualChangesToDatepicker(selectedDate); - } - } - - private replaceDatesWithNewSelection(activeField:DateFields, selectedDate:Date) { - /** - Overwrite flatpickr default behavior by not starting a new date range everytime but preserving either start or end date. - There are three cases to cover. - 1. Everything before the current start date will become the new start date (independent of the active field) - 2. Everything after the current end date will become the new end date if that is the currently active field. - If the active field is the start date, the selected date becomes the new start date and the end date is cleared. - 3. Everything in between the current start and end date is dependent on the currently activated field. - * */ - - const parsedStartDate = parseDate(this.dates.start || '') as Date; - const parsedEndDate = parseDate(this.dates.end || '') as Date; - - if (selectedDate < parsedStartDate) { - if (activeField === 'start') { - // Set start, derive end from duration - this.applyNewDates([selectedDate]); - } else { - // Reset duration and end date - this.duration = null; - this.applyNewDates(['', selectedDate]); - } - } else if (selectedDate > parsedEndDate) { - if (activeField === 'end') { - this.applyNewDates([parsedStartDate, selectedDate]); - } else { - // Reset duration and end date - this.duration = null; - this.applyNewDates([selectedDate]); - } - } else if (areDatesEqual(selectedDate, parsedStartDate) || areDatesEqual(selectedDate, parsedEndDate)) { - this.applyNewDates([selectedDate, selectedDate]); - } else { - const newDates = activeField === 'start' ? [selectedDate, parsedEndDate] : [parsedStartDate, selectedDate]; - this.applyNewDates(newDates); - } - } - - private applyNewDates([start, end]:DateOption[]) { - this.dates.start = start ? this.timezoneService.formattedISODate(start) : null; - this.dates.end = end ? this.timezoneService.formattedISODate(end) : null; - - // Apply the dates to the datepicker - setDates([start, end], this.datePickerInstance); - - // We updated either start, end, or both fields - // If both are now set, we want to derive duration from them - if (this.dates.start && this.dates.end) { - this.formUpdates$.next({ startDate: this.dates.start, dueDate: this.dates.end }); - } - - // If only one is set, derive from duration - if (this.dates.start && !this.dates.end && !!this.duration) { - this.formUpdates$.next({ startDate: this.dates.start, duration: this.durationAsIso8601 }); - } - - if (this.dates.end && !this.dates.start && !!this.duration) { - this.formUpdates$.next({ dueDate: this.dates.end, duration: this.durationAsIso8601 }); - } - } - - private get initialActivatedField():DateFields { - switch (this.fieldName) { - case 'startDate': - return 'start'; - case 'dueDate': - return 'end'; - case 'duration': - return 'duration'; - default: - return (this.dates.start && !this.dates.end) ? 'end' : 'start'; - } - } - - private isDayDisabled(dayElement:DayElement, minimalDate?:Date|null):boolean { - return !this.isSchedulable || (!this.scheduleManually && !!minimalDate && dayElement.dateObj <= minimalDate); - } - - /** - * Update the datepicker dates and properties from a form response - * that includes derived/calculated values. - * - * @param form - * @private - */ - private updateDatesFromForm(form:FormResource):void { - const payload = form.payload as { startDate:string, dueDate:string, duration:string, ignoreNonWorkingDays:boolean }; - this.dates.start = payload.startDate; - this.dates.end = payload.dueDate; - this.ignoreNonWorkingDays = payload.ignoreNonWorkingDays; - - this.setDurationDaysFromUpstream(payload.duration); - - const parsedStartDate = parseDate(this.dates.start) as Date; - this.enforceManualChangesToDatepicker(parsedStartDate); - this.cdRef.detectChanges(); - } - - /** - * Updates the duration property and the displayed value - * @param value a ISO8601 duration string or null - * @private - */ - private setDurationDaysFromUpstream(value:string|null) { - const durationDays = value ? this.timezoneService.toDays(value) : null; - - if (!durationDays || durationDays === 0) { - this.duration = null; - } else { - this.duration = durationDays; - } - } - - private debouncedInput(input$:Subject, key:DateKeys):Observable { - return input$ - .pipe( - this.untilDestroyed(), - // Skip values that are already set as the current model - filter((value) => value !== this.dates[key]), - // Avoid that the manual changes are moved to the datepicker too early. - // The debounce is chosen quite large on purpose to catch the following case: - // 1. Start date is for example 2022-07-15. The user wants to set the end date to the 19th. - // 2. So he/she starts entering the finish date 2022-07-1 . - // 3. This is already a valid date. Since it is before the start date,the start date would be changed automatically to the first without the debounce. - // 4. The debounce gives the user enough time to type the last number "9" before the changes are converted to the datepicker and the start date would be affected. - debounceTime(500), - filter((date) => validDate(date)), - map((date) => { - if (date === '') { - return null; - } - - return parseDate(date) as Date; - }), - map((date) => [key, date]), - ); - } - - /** - * When hovering selections in the range datepicker, the range usually - * stays active no matter where the cursor is. - * - * We want to hide any hovered selection preview when we leave the datepicker. - * @param calendarContainer - * @private - */ - private ensureHoveredSelection(calendarContainer:HTMLDivElement) { - fromEvent(calendarContainer, 'mouseenter') - .pipe( - this.untilDestroyed(), - ) - .subscribe(() => calendarContainer.classList.remove('flatpickr-container-suppress-hover')); - - fromEvent(calendarContainer, 'mouseleave') - .pipe( - this.untilDestroyed(), - filter(() => !(!!this.dates.start && !!this.dates.end)), - ) - .subscribe(() => calendarContainer.classList.add('flatpickr-container-suppress-hover')); - } -} diff --git a/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.html b/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.html index 389e152c51f4..6b97748c11f0 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.html +++ b/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.html @@ -28,7 +28,7 @@ [resource]="resource" (successfulCreate)="handleSuccessfulCreate($event)" (successfulUpdate)="handleSuccessfulUpdate()" - (cancel)="onModalClosed()" + (cancel)="cancel()" id="wp-datepicker-dialog--content"> @@ -40,14 +40,5 @@ - - diff --git a/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.ts index 307b2cdf7663..1060a96c101a 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.ts @@ -56,8 +56,6 @@ export class CombinedDateEditFieldComponent extends DatePickerEditFieldComponent opened = false; - turboFrameSrc:string; - text = { placeholder: { startDate: this.I18n.t('js.label_no_start_date'), @@ -66,22 +64,8 @@ export class CombinedDateEditFieldComponent extends DatePickerEditFieldComponent }, }; - constructor( - readonly I18n:I18nService, - readonly elementRef:ElementRef, - @Inject(OpEditingPortalChangesetToken) protected change:ResourceChangeset, - @Inject(OpEditingPortalSchemaToken) public schema:IFieldSchema, - @Inject(OpEditingPortalHandlerToken) readonly handler:EditFieldHandler, - readonly cdRef:ChangeDetectorRef, - readonly injector:Injector, - readonly pathHelper:PathHelperService, - ) { - super(I18n, elementRef, change, schema, handler, cdRef, injector); - } - ngOnInit() { super.ngOnInit(); - this.turboFrameSrc = `${this.pathHelper.workPackageDatepickerDialogContentPath(this.change.id)}?field=${this.name}`; } get isMultiDate():boolean { @@ -105,23 +89,12 @@ export class CombinedDateEditFieldComponent extends DatePickerEditFieldComponent this.resetDates(); } - public handleSuccessfulCreate(JSONResponse:{ duration:number, startDate:Date, dueDate:Date, includeNonWorkingDays:boolean, scheduleManually:boolean }):void { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment - this.resource.duration = JSONResponse.duration; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment - this.resource.dueDate = JSONResponse.dueDate; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment - this.resource.startDate = JSONResponse.startDate; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment - this.resource.includeNonWorkingDays = JSONResponse.includeNonWorkingDays; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment - this.resource.scheduleManually = JSONResponse.scheduleManually; - - this.onModalClosed(); + public save():void { + this.handler.handleUserSubmit(); } - public handleSuccessfulUpdate():void { - this.onModalClosed(); + public cancel():void { + this.handler.reset(); } // Overwrite super in order to set the initial dates. diff --git a/frontend/src/app/shared/components/fields/edit/field-types/date-picker-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/date-picker-edit-field.component.ts index a08dfa29ab44..0b2a2e833abd 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/date-picker-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/date-picker-edit-field.component.ts @@ -31,11 +31,25 @@ import { OnDestroy, OnInit, Injector, + ElementRef, + Inject, + ChangeDetectorRef, } from '@angular/core'; import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; import { TimezoneService } from 'core-app/core/datetime/timezone.service'; -import { EditFieldComponent } from 'core-app/shared/components/fields/edit/edit-field.component'; +import { + EditFieldComponent, + OpEditingPortalChangesetToken, + OpEditingPortalHandlerToken, + OpEditingPortalSchemaToken, +} from 'core-app/shared/components/fields/edit/edit-field.component'; import { DeviceService } from 'core-app/core/browser/device.service'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset'; +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { IFieldSchema } from 'core-app/shared/components/fields/field.base'; +import { EditFieldHandler } from 'core-app/shared/components/fields/edit/editing-portal/edit-field-handler'; @Directive() export abstract class DatePickerEditFieldComponent extends EditFieldComponent implements OnInit, OnDestroy { @@ -43,10 +57,24 @@ export abstract class DatePickerEditFieldComponent extends EditFieldComponent im @InjectField() deviceService:DeviceService; - @InjectField() injector:Injector; + turboFrameSrc:string; + + constructor( + readonly I18n:I18nService, + readonly elementRef:ElementRef, + @Inject(OpEditingPortalChangesetToken) protected change:ResourceChangeset, + @Inject(OpEditingPortalSchemaToken) public schema:IFieldSchema, + @Inject(OpEditingPortalHandlerToken) readonly handler:EditFieldHandler, + readonly cdRef:ChangeDetectorRef, + readonly injector:Injector, + readonly pathHelper:PathHelperService, + ) { + super(I18n, elementRef, change, schema, handler, cdRef, injector); + } ngOnInit():void { super.ngOnInit(); + this.turboFrameSrc = `${this.pathHelper.workPackageDatepickerDialogContentPath(this.change.id)}?field=${this.name}`; this.handler .$onUserActivate @@ -63,4 +91,25 @@ export abstract class DatePickerEditFieldComponent extends EditFieldComponent im } public showDatePickerModal():void { } + + public handleSuccessfulCreate(JSONResponse:{ duration:number, startDate:Date, dueDate:Date, includeNonWorkingDays:boolean, scheduleManually:boolean }):void { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.duration = JSONResponse.duration; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.dueDate = JSONResponse.dueDate; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.startDate = JSONResponse.startDate; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.includeNonWorkingDays = JSONResponse.includeNonWorkingDays; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.scheduleManually = JSONResponse.scheduleManually; + + this.onModalClosed(); + } + + public handleSuccessfulUpdate():void { + this.onModalClosed(); + } + + public onModalClosed():void { } } diff --git a/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.html b/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.html index 1e64d5f96dfb..316ecf8f304e 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.html +++ b/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.html @@ -13,12 +13,25 @@ disabled="disabled" [id]="handler.htmlId" /> - - + + + + + + + + + + + + + diff --git a/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.ts index 6545320ea559..d3ece8129151 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.ts @@ -28,16 +28,32 @@ import { ChangeDetectionStrategy, + ChangeDetectorRef, Component, + ElementRef, + Inject, + Injector, + OnInit, } from '@angular/core'; import { DatePickerEditFieldComponent } from 'core-app/shared/components/fields/edit/field-types/date-picker-edit-field.component'; import * as moment from 'moment-timezone'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { + OpEditingPortalChangesetToken, + OpEditingPortalHandlerToken, + OpEditingPortalSchemaToken, +} from 'core-app/shared/components/fields/edit/edit-field.component'; +import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset'; +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { IFieldSchema } from 'core-app/shared/components/fields/field.base'; +import { EditFieldHandler } from 'core-app/shared/components/fields/edit/editing-portal/edit-field-handler'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; @Component({ templateUrl: './days-duration-edit-field.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class DaysDurationEditFieldComponent extends DatePickerEditFieldComponent { +export class DaysDurationEditFieldComponent extends DatePickerEditFieldComponent implements OnInit{ opened = false; public get formattedValue():number { @@ -56,7 +72,7 @@ export class DaysDurationEditFieldComponent extends DatePickerEditFieldComponent this.opened = true; } - save() { + onModalClosed() { this.handler.handleUserSubmit(); this.opened = false; }