,
+ @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,66 @@ 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 ? this.timezoneService.toISODuration(JSONResponse.duration, 'days') : null;
+ // 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 { }
+
+ public updateFrameSrc():void {
+ const url = new URL(
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ this.pathHelper.workPackageDatepickerDialogContentPath(this.resource.id as string),
+ window.location.origin,
+ );
+
+ url.searchParams.set('field', this.name);
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access
+ url.searchParams.set('work_package[initial][start_date]', this.nullAsEmptyStringFormatter(this.resource.startDate));
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access
+ url.searchParams.set('work_package[initial][due_date]', this.nullAsEmptyStringFormatter(this.resource.dueDate));
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access
+ url.searchParams.set('work_package[initial][duration]', this.nullAsEmptyStringFormatter(this.resource.duration));
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ url.searchParams.set('work_package[initial][ignore_non_working_days]', this.nullAsEmptyStringFormatter(this.resource.includeNonWorkingDays));
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access
+ url.searchParams.set('work_package[start_date]', this.nullAsEmptyStringFormatter(this.resource.startDate));
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access
+ url.searchParams.set('work_package[due_date]', this.nullAsEmptyStringFormatter(this.resource.dueDate));
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access
+ url.searchParams.set('work_package[duration]', this.nullAsEmptyStringFormatter(this.resource.duration));
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ url.searchParams.set('work_package[ignore_non_working_days]', this.nullAsEmptyStringFormatter(this.resource.includeNonWorkingDays));
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ if (this.resource?.id === 'new') {
+ url.searchParams.set('work_package[start_date_touched]', 'true');
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ this.turboFrameSrc = url.toString();
+ }
+
+ private nullAsEmptyStringFormatter(value:null|string):string {
+ if (value === undefined || value === null) {
+ return '';
+ }
+ return value;
+ }
}
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..36a166bc0748 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
@@ -29,6 +29,7 @@
import {
ChangeDetectionStrategy,
Component,
+ 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';
@@ -37,7 +38,7 @@ import * as moment from 'moment-timezone';
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,8 +57,8 @@ export class DaysDurationEditFieldComponent extends DatePickerEditFieldComponent
this.opened = true;
}
- save() {
- this.handler.handleUserSubmit();
+ onModalClosed() {
+ void this.handler.handleUserSubmit();
this.opened = false;
}
diff --git a/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.html b/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.html
index 73a7cba63181..d3e433c89e15 100644
--- a/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.html
+++ b/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.html
@@ -37,9 +37,15 @@
+ [attr.src]="this.frameSrc"
+ opModalWithTurboContent
+ [change]="change"
+ [resource]="resource"
+ (successfulCreate)="handleSuccessfulCreate($event)"
+ (successfulUpdate)="handleSuccessfulUpdate()"
+ (cancel)="cancel()"
+ >
diff --git a/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.ts
index 8abf179696ee..8660af62d91b 100644
--- a/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.ts
+++ b/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.ts
@@ -29,22 +29,17 @@
*/
import {
- AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
Inject,
Injector,
- OnDestroy,
OnInit,
- ViewChild,
} from '@angular/core';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
-import {
- ProgressEditFieldComponent,
-} from 'core-app/shared/components/fields/edit/field-types/progress-edit-field.component';
+import { ProgressEditFieldComponent } from 'core-app/shared/components/fields/edit/field-types/progress-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';
@@ -55,7 +50,6 @@ import {
OpEditingPortalSchemaToken,
} from 'core-app/shared/components/fields/edit/edit-field.component';
import { HalEventsService } from 'core-app/features/hal/services/hal-events.service';
-import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
import { ToastService } from 'core-app/shared/components/toaster/toast.service';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { TimezoneService } from 'core-app/core/datetime/timezone.service';
@@ -65,9 +59,7 @@ import { TimezoneService } from 'core-app/core/datetime/timezone.service';
styleUrls: ['./progress-popover-edit-field.component.sass'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class ProgressPopoverEditFieldComponent extends ProgressEditFieldComponent implements OnInit, AfterViewInit, OnDestroy {
- @ViewChild('frameElement') frameElement:ElementRef;
-
+export class ProgressPopoverEditFieldComponent extends ProgressEditFieldComponent implements OnInit {
text = {
title: this.I18n.t('js.work_packages.progress.title'),
button_close: this.I18n.t('js.button_close'),
@@ -107,22 +99,6 @@ export class ProgressPopoverEditFieldComponent extends ProgressEditFieldComponen
this.frameId = 'work_package_progress_modal';
}
- ngAfterViewInit() {
- this
- .frameElement
- .nativeElement
- .addEventListener('turbo:submit-end', this.contextBasedListener.bind(this));
- }
-
- ngOnDestroy() {
- super.ngOnDestroy();
-
- this
- .frameElement
- .nativeElement
- .removeEventListener('turbo:submit-end', this.contextBasedListener.bind(this));
- }
-
public get asHoursOrPercent():string {
return this.name === 'percentageDone' ? this.asPercent : this.asHours;
}
@@ -156,62 +132,19 @@ export class ProgressPopoverEditFieldComponent extends ProgressEditFieldComponen
return value;
}
- private contextBasedListener(event:CustomEvent) {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
- if (this.resource.id === 'new') {
- void this.propagateSuccessfulCreate(event);
- } else {
- this.propagateSuccessfulUpdate(event);
- }
- }
-
- private async propagateSuccessfulCreate(event:CustomEvent) {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
- const { fetchResponse } = event.detail;
-
- // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
- if (fetchResponse.succeeded) {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access
- const JSONresponse = await this.extractJSONFromResponse(fetchResponse.response.body);
-
- // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment
- this.resource.estimatedTime = JSONresponse.estimatedTime;
- // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment
- this.resource.remainingTime = JSONresponse.remainingTime;
- // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment
- this.resource.percentageDone = JSONresponse.percentageDone;
+ public handleSuccessfulCreate(JSONResponse:{ estimatedTime:string, remainingTime:string, percentageDone:string }):void {
+// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment
+ this.resource.estimatedTime = JSONResponse.estimatedTime;
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment
+ this.resource.remainingTime = JSONResponse.remainingTime;
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment
+ this.resource.percentageDone = JSONResponse.percentageDone;
- this.onModalClosed();
-
- this.change.push();
- this.cdRef.detectChanges();
- }
- }
-
- private propagateSuccessfulUpdate(event:CustomEvent) {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
- const { fetchResponse } = event.detail;
-
- // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
- if (fetchResponse.succeeded) {
- this.halEvents.push(
- this.resource as WorkPackageResource,
- { eventType: 'updated' },
- );
-
- void this.apiV3Service.work_packages.id(this.resource as WorkPackageResource).refresh();
-
- this.onModalClosed();
-
- this.toastService.addSuccess(this.I18n.t('js.notice_successful_update'));
- }
+ this.onModalClosed();
}
- private async extractJSONFromResponse(response:ReadableStream) {
- const readStream = await response.getReader().read();
-
- // eslint-disable-next-line @typescript-eslint/no-unsafe-return
- return JSON.parse(new TextDecoder('utf-8').decode(new Uint8Array(readStream.value as ArrayBufferLike)));
+ public handleSuccessfulUpdate():void {
+ this.onModalClosed();
}
private updateFrameSrc():void {
diff --git a/frontend/src/app/shared/components/fields/edit/modal-with-turbo-content/modal-with-turbo-content.directive.ts b/frontend/src/app/shared/components/fields/edit/modal-with-turbo-content/modal-with-turbo-content.directive.ts
new file mode 100644
index 000000000000..c6ecda1d8139
--- /dev/null
+++ b/frontend/src/app/shared/components/fields/edit/modal-with-turbo-content/modal-with-turbo-content.directive.ts
@@ -0,0 +1,139 @@
+//-- 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,
+ ChangeDetectorRef,
+ Directive,
+ ElementRef,
+ EventEmitter,
+ Input,
+ OnDestroy,
+ Output,
+} from '@angular/core';
+import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
+import { HalResource } from 'core-app/features/hal/resources/hal-resource';
+import { HalEventsService } from 'core-app/features/hal/services/hal-events.service';
+import { ToastService } from 'core-app/shared/components/toaster/toast.service';
+import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
+import { I18nService } from 'core-app/core/i18n/i18n.service';
+import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset';
+
+@Directive({
+ selector: '[opModalWithTurboContent]',
+})
+export class ModalWithTurboContentDirective implements AfterViewInit, OnDestroy {
+ @Input() resource:HalResource;
+ @Input() change:ResourceChangeset;
+
+ @Output() successfulCreate= new EventEmitter();
+ @Output() successfulUpdate= new EventEmitter();
+ @Output() cancel= new EventEmitter();
+
+ constructor(
+ readonly elementRef:ElementRef,
+ readonly cdRef:ChangeDetectorRef,
+ readonly halEvents:HalEventsService,
+ readonly apiV3Service:ApiV3Service,
+ readonly toastService:ToastService,
+ readonly I18n:I18nService,
+ ) {
+
+ }
+
+ ngAfterViewInit() {
+ (this.elementRef.nativeElement as HTMLElement)
+ .addEventListener('turbo:submit-end', this.contextBasedListener.bind(this));
+
+ document
+ .addEventListener('cancelModalWithTurboContent', this.cancelListener.bind(this));
+ }
+
+ ngOnDestroy() {
+ (this.elementRef.nativeElement as HTMLElement)
+ .removeEventListener('turbo:submit-end', this.contextBasedListener.bind(this));
+
+ document
+ .removeEventListener('cancelModalWithTurboContent', this.cancelListener.bind(this));
+ }
+
+ private contextBasedListener(event:CustomEvent) {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ if (this.resource.id === 'new') {
+ void this.propagateSuccessfulCreate(event);
+ } else {
+ this.propagateSuccessfulUpdate(event);
+ }
+ }
+
+ private cancelListener():void {
+ this.cancel.emit();
+ }
+
+ private async propagateSuccessfulCreate(event:CustomEvent) {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const { fetchResponse } = event.detail;
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ if (fetchResponse.succeeded) {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access
+ const JSONresponse:unknown = await this.extractJSONFromResponse(fetchResponse.response.body);
+
+ this.successfulCreate.emit(JSONresponse);
+
+ this.change.push();
+ this.cdRef.detectChanges();
+ }
+ }
+
+ private propagateSuccessfulUpdate(event:CustomEvent) {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const { fetchResponse } = event.detail;
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ if (fetchResponse.succeeded) {
+ this.halEvents.push(
+ this.resource as WorkPackageResource,
+ { eventType: 'updated' },
+ );
+
+ void this.apiV3Service.work_packages.id(this.resource as WorkPackageResource).refresh();
+
+ this.successfulUpdate.emit();
+
+ this.toastService.addSuccess(this.I18n.t('js.notice_successful_update'));
+ }
+ }
+
+ private async extractJSONFromResponse(response:ReadableStream) {
+ const readStream = await response.getReader().read();
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
+ return JSON.parse(new TextDecoder('utf-8').decode(new Uint8Array(readStream.value as ArrayBufferLike)));
+ }
+}
diff --git a/frontend/src/app/shared/components/fields/openproject-fields.module.ts b/frontend/src/app/shared/components/fields/openproject-fields.module.ts
index ffb58fa6bad5..ef4a2c64b97f 100644
--- a/frontend/src/app/shared/components/fields/openproject-fields.module.ts
+++ b/frontend/src/app/shared/components/fields/openproject-fields.module.ts
@@ -106,6 +106,7 @@ import {
import { CombinedDateEditFieldComponent } from './edit/field-types/combined-date-edit-field.component';
import { NgSelectModule } from '@ng-select/ng-select';
import { FormsModule } from '@angular/forms';
+import { ModalWithTurboContentDirective } from 'core-app/shared/components/fields/edit/modal-with-turbo-content/modal-with-turbo-content.directive';
@NgModule({
imports: [
@@ -169,6 +170,8 @@ import { FormsModule } from '@angular/forms';
AttributeLabelMacroComponent,
WorkPackageQuickinfoMacroComponent,
+
+ ModalWithTurboContentDirective,
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts
index 00be06fa2270..2fd5edc8b056 100644
--- a/frontend/src/app/shared/shared.module.ts
+++ b/frontend/src/app/shared/shared.module.ts
@@ -67,12 +67,6 @@ import { CopyToClipboardService } from './components/copy-to-clipboard/copy-to-c
import { CopyToClipboardComponent } from './components/copy-to-clipboard/copy-to-clipboard.component';
import { OpDateTimeComponent } from './components/date/op-date-time.component';
import { ToastComponent } from './components/toaster/toast.component';
-
-// Old datepickers
-import {
- OpMultiDatePickerComponent,
-} from 'core-app/shared/components/datepicker/multi-date-picker/multi-date-picker.component';
-
import { ToastsContainerComponent } from './components/toaster/toasts-container.component';
import { UploadProgressComponent } from './components/toaster/upload-progress.component';
import { ResizerComponent } from './components/resizer/resizer.component';
@@ -197,9 +191,6 @@ export function bootstrapModule(injector:Injector):void {
OpProjectIncludeListComponent,
OpLoadingProjectListComponent,
- // Old datepickers
- OpMultiDatePickerComponent,
-
OpNonWorkingDaysListComponent,
],
providers: [
@@ -251,9 +242,6 @@ export function bootstrapModule(injector:Injector):void {
OpNonWorkingDaysListComponent,
- // Old datepickers
- OpMultiDatePickerComponent,
-
ShareUpsaleComponent,
],
})
diff --git a/frontend/src/global_styles/content/_work_packages.sass b/frontend/src/global_styles/content/_work_packages.sass
index 74429a4b1be6..89faf9f14e74 100644
--- a/frontend/src/global_styles/content/_work_packages.sass
+++ b/frontend/src/global_styles/content/_work_packages.sass
@@ -71,3 +71,5 @@
// Resizer
@import work_packages/resizer/resizer
+// Generic Datepicker modal styles
+@import work_packages/datepicker_modal
diff --git a/frontend/src/global_styles/content/work_packages/_datepicker_modal.sass b/frontend/src/global_styles/content/work_packages/_datepicker_modal.sass
new file mode 100644
index 000000000000..f02149124f6e
--- /dev/null
+++ b/frontend/src/global_styles/content/work_packages/_datepicker_modal.sass
@@ -0,0 +1,35 @@
+.op-datepicker-modal
+ display: flex
+ flex-direction: column
+ height: 100%
+
+ @media #{$spot-mq-drop-modal-in-context}
+ height: unset
+ min-height: 475px // Avoid jump in height when the calendar has 6 rows to display all weeks
+ width: 100vw
+ // Basically the width of the two calendars next to each other + spacings
+ // will be overwritten on mobile
+ max-width: 320px
+
+ &--date-field
+ &_current,
+ &_current:hover
+ border: 2px solid var(--control-checked-color) !important
+
+ &--flatpickr-instance.inline
+ margin: 0.5rem auto 0 auto !important
+ overflow: hidden
+
+@media screen and (max-width: $breakpoint-sm)
+ .op-datepicker-modal
+ // Use same width as spot-modal mobile
+ width: calc(100vw - 2rem)
+ margin-bottom: 0
+
+ &--flatpickr-instance
+ align-self: center
+
+.flatpickr-wrapper
+ // Make flatpickr behave correctly when it is instantiated
+ // inside a dialog using the static: true option.
+ width: 100%
diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass
index b458b00a2e9d..ae226ff0cffa 100644
--- a/frontend/src/global_styles/primer/_overrides.sass
+++ b/frontend/src/global_styles/primer/_overrides.sass
@@ -50,6 +50,7 @@
page-header,
sub-header,
.op-work-package-details-tab-component,
+.UnderlineNav,
.tabnav,
.Box-header,
action-menu anchored-position
diff --git a/frontend/src/stimulus/controllers/dynamic/work-packages/date-picker/preview.controller.ts b/frontend/src/stimulus/controllers/dynamic/work-packages/date-picker/preview.controller.ts
new file mode 100644
index 000000000000..9ca48678975f
--- /dev/null
+++ b/frontend/src/stimulus/controllers/dynamic/work-packages/date-picker/preview.controller.ts
@@ -0,0 +1,119 @@
+/*
+ * -- 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 { DialogPreviewController } from '../dialog/preview.controller';
+import { TimezoneService } from 'core-app/core/datetime/timezone.service';
+
+export default class PreviewController extends DialogPreviewController {
+ private timezoneService:TimezoneService;
+
+ async connect() {
+ super.connect();
+
+ const context = await window.OpenProject.getPluginContext();
+ this.timezoneService = context.services.timezone;
+ }
+
+ markFieldAsTouched(event:{ target:HTMLInputElement }) {
+ super.markFieldAsTouched(event);
+ }
+
+ // Ensures that on create forms, there is an "id" for the un-persisted
+ // work package when sending requests to the edit action for previews.
+ ensureValidPathname(formAction:string):string {
+ const wpPath = new URL(formAction);
+
+ if (wpPath.pathname.endsWith('/work_packages/datepicker_dialog_content')) {
+ // Replace /work_packages/date_picker with /work_packages/new/date_picker
+ wpPath.pathname = wpPath.pathname.replace('/work_packages/datepicker_dialog_content', '/work_packages/new/datepicker_dialog_content');
+ }
+
+ return wpPath.toString();
+ }
+
+ ensureValidWpAction(wpPath:string):string {
+ return wpPath.endsWith('/work_packages/new/datepicker_dialog_content') ? 'new' : 'edit';
+ }
+
+ dispatchChangeEvent(field:HTMLInputElement) {
+ document.dispatchEvent(
+ new CustomEvent('date-picker:input-changed', {
+ detail: {
+ field: field.name,
+ value: this.getValueFor(field),
+ },
+ }),
+ );
+ }
+
+ private getValueFor(field:HTMLInputElement):string {
+ if (field.type === 'checkbox') {
+ return field.checked.toString();
+ }
+
+ return field.value;
+ }
+
+ highlightField(e:Event) {
+ const newHighlightedField = e.target;
+ if (newHighlightedField) {
+ Array.from(document.getElementsByClassName('op-datepicker-modal--date-field_current')).forEach(
+ (el) => {
+ el.classList.remove('op-datepicker-modal--date-field_current');
+ el.removeAttribute('data-qa-highlighted');
+ },
+ );
+
+ (newHighlightedField as HTMLInputElement).classList.add('op-datepicker-modal--date-field_current');
+ (newHighlightedField as HTMLInputElement).dataset.qaHighlighted = 'true';
+
+ document.dispatchEvent(
+ new CustomEvent('date-picker:input-focused', {
+ detail: {
+ field: (newHighlightedField as HTMLInputElement).name,
+ },
+ }),
+ );
+ }
+ }
+
+ setTodayForField(event:unknown) {
+ (event as Event).preventDefault();
+
+ const targetFieldID = (event as { params:{ fieldReference:string } }).params.fieldReference;
+ if (targetFieldID) {
+ const inputField = document.getElementById(targetFieldID);
+ if (inputField) {
+ (inputField as HTMLInputElement).value = this.timezoneService.formattedISODate(Date.now());
+ inputField.dispatchEvent(new Event('input'));
+ }
+ }
+ }
+}
diff --git a/frontend/src/stimulus/controllers/dynamic/work-packages/dialog/preview.controller.ts b/frontend/src/stimulus/controllers/dynamic/work-packages/dialog/preview.controller.ts
new file mode 100644
index 000000000000..707f96186351
--- /dev/null
+++ b/frontend/src/stimulus/controllers/dynamic/work-packages/dialog/preview.controller.ts
@@ -0,0 +1,248 @@
+/*
+ * -- 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 { Controller } from '@hotwired/stimulus';
+import {
+ debounce,
+ DebouncedFunc,
+} from 'lodash';
+import Idiomorph from 'idiomorph/dist/idiomorph.cjs';
+
+interface TurboBeforeFrameRenderEventDetail {
+ render:(currentElement:HTMLElement, newElement:HTMLElement) => void;
+}
+
+interface HTMLTurboFrameElement extends HTMLElement {
+ src:string;
+}
+
+export abstract class DialogPreviewController extends Controller {
+ static targets = [
+ 'form',
+ 'fieldInput',
+ 'initialValueInput',
+ 'touchedFieldInput',
+ ];
+
+ declare readonly fieldInputTargets:HTMLInputElement[];
+ declare readonly formTarget:HTMLFormElement;
+ declare readonly initialValueInputTargets:HTMLInputElement[];
+ declare readonly touchedFieldInputTargets:HTMLInputElement[];
+
+ private debouncedPreview:DebouncedFunc<(event:Event) => void>;
+ private frameMorphRenderer:(event:CustomEvent) => void;
+ private targetFieldName:string;
+ private touchedFields:Set;
+
+ connect() {
+ this.touchedFields = new Set();
+ this.touchedFieldInputTargets.forEach((input) => {
+ const fieldName = input.dataset.referrerField;
+ if (fieldName && input.value === 'true') {
+ this.touchedFields.add(fieldName);
+ }
+ });
+
+ // if the debounce value is changed, the following test helper must be kept
+ // in sync: `spec/support/edit_fields/progress_edit_field.rb`, method `#wait_for_preview_to_complete`
+ this.debouncedPreview = debounce((event:Event) => { void this.preview(event); }, 200);
+
+ // Turbo supports morphing, by adding the
+ // attribute. However, it does not work that well with primer input: when
+ // adding "data-turbo-permanent" to keep value and focus on the active
+ // element, it also keeps the `aria-describedby` attribute which references
+ // caption and validation element ids. As these elements are morphed and get
+ // new ids, the ids referenced by `aria-describedby` are stale. This makes
+ // caption and validation message unaccessible for screen readers and other
+ // assistive technologies. This is why morph cannot be used here.
+ this.frameMorphRenderer = (event:CustomEvent) => {
+ event.detail.render = (currentElement:HTMLElement, newElement:HTMLElement) => {
+ Idiomorph.morph(currentElement, newElement, {
+ ignoreActiveValue: true,
+ callbacks: {
+ beforeNodeMorphed: (oldNode:Element) => {
+ // In case the element is an OpenProject custom dom element, morphing is prevented.
+ return !oldNode.tagName?.startsWith('OPCE-');
+ },
+ afterNodeMorphed: (oldNode:Element, newNode:Element) => {
+ if (newNode.tagName === 'INPUT' && (newNode as HTMLInputElement).name && (newNode as HTMLInputElement).name.startsWith('work_package[')) {
+ this.dispatchChangeEvent((newNode as HTMLInputElement));
+ }
+ },
+ },
+ });
+ };
+ };
+
+ this.fieldInputTargets.forEach((target) => {
+ if (target.tagName.toLowerCase() === 'select') {
+ target.addEventListener('change', this.debouncedPreview);
+ } else {
+ target.addEventListener('input', this.debouncedPreview);
+ }
+
+ if (target.dataset.focus === 'true') {
+ this.focusAndSetCursorPositionToEndOfInput(target);
+ }
+ });
+
+ const turboFrame = this.formTarget.closest('turbo-frame') as HTMLTurboFrameElement;
+ turboFrame.addEventListener('turbo:before-frame-render', this.frameMorphRenderer);
+ }
+
+ disconnect() {
+ this.debouncedPreview.cancel();
+ this.fieldInputTargets.forEach((target) => {
+ if (target.tagName.toLowerCase() === 'select') {
+ target.removeEventListener('change', this.debouncedPreview);
+ } else {
+ target.removeEventListener('input', this.debouncedPreview);
+ }
+ });
+ const turboFrame = this.formTarget.closest('turbo-frame') as HTMLTurboFrameElement;
+ if (turboFrame) {
+ turboFrame.removeEventListener('turbo:before-frame-render', this.frameMorphRenderer);
+ }
+ }
+
+ protected cancel():void {
+ document.dispatchEvent(new CustomEvent('cancelModalWithTurboContent'));
+ }
+
+ markFieldAsTouched(event:{ target:HTMLInputElement }) {
+ this.targetFieldName = event.target.name.replace(/^work_package\[([^\]]+)\]$/, '$1');
+ this.markTouched(this.targetFieldName);
+ }
+
+ async preview(event:Event) {
+ let field:HTMLInputElement;
+ if (event.type === 'blur') {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ field = (event as FocusEvent).relatedTarget as HTMLInputElement;
+ } else {
+ field = event.target as HTMLInputElement;
+ }
+
+ const form = this.formTarget;
+ const formData = new FormData(form) as unknown as undefined;
+ const formParams = new URLSearchParams(formData);
+
+ const wpParams = Array.from(formParams.entries())
+ .filter(([key, _]) => key.startsWith('work_package'));
+ wpParams.push(['field', field?.name ?? '']);
+
+ const wpPath = this.ensureValidPathname(form.action);
+ const wpAction = this.ensureValidWpAction(wpPath);
+
+ const editUrl = `${wpPath}/${wpAction}?${new URLSearchParams(wpParams).toString()}`;
+ const turboFrame = this.formTarget.closest('turbo-frame') as HTMLTurboFrameElement;
+
+ if (turboFrame) {
+ turboFrame.src = editUrl;
+ }
+ }
+
+ private focusAndSetCursorPositionToEndOfInput(field:HTMLInputElement) {
+ field.focus();
+ field.setSelectionRange(
+ field.value.length,
+ field.value.length,
+ );
+ }
+
+ abstract ensureValidPathname(formAction:string):string;
+
+ abstract ensureValidWpAction(path:string):string;
+
+ abstract dispatchChangeEvent(field:HTMLInputElement|null):void;
+
+ protected isBeingEdited(fieldName:string) {
+ return fieldName === this.targetFieldName;
+ }
+
+ // Finds the hidden initial value input based on a field name.
+ //
+ // The initial value input field holds the initial value of the work package
+ // before being set by the user or derived.
+ private findInitialValueInput(fieldName:string):HTMLInputElement|undefined {
+ return this.initialValueInputTargets.find((input) =>
+ (input.dataset.referrerField === fieldName));
+ }
+
+ // Finds the value field input based on a field name.
+ //
+ // The value field input holds the current value of a field.
+ protected findValueInput(fieldName:string):HTMLInputElement|undefined {
+ return this.fieldInputTargets.find((input) =>
+ (input.name === fieldName) || (input.name === `work_package[${fieldName}]`));
+ }
+
+ protected isTouchedAndEmpty(fieldName:string):boolean {
+ return this.isTouched(fieldName) && this.isValueEmpty(fieldName);
+ }
+
+ protected isTouched(fieldName:string):boolean {
+ return this.touchedFields.has(fieldName);
+ }
+
+ protected isInitialValueEmpty(fieldName:string):boolean {
+ const valueInput = this.findInitialValueInput(fieldName);
+ return valueInput?.value === '';
+ }
+
+ protected isValueEmpty(fieldName:string):boolean {
+ const valueInput = this.findValueInput(fieldName);
+ return valueInput?.value === '';
+ }
+
+ protected isValueSet(fieldName:string):boolean {
+ const valueInput = this.findValueInput(fieldName);
+ return valueInput !== undefined && valueInput.value !== '';
+ }
+
+ protected markTouched(fieldName:string):void {
+ this.touchedFields.add(fieldName);
+ this.updateTouchedFieldHiddenInputs();
+ }
+
+ protected markUntouched(fieldName:string):void {
+ this.touchedFields.delete(fieldName);
+ this.updateTouchedFieldHiddenInputs();
+ }
+
+ private updateTouchedFieldHiddenInputs():void {
+ this.touchedFieldInputTargets.forEach((input) => {
+ const fieldName = input.dataset.referrerField;
+ if (fieldName) {
+ input.value = this.isTouched(fieldName) ? 'true' : 'false';
+ }
+ });
+ }
+}
diff --git a/frontend/src/stimulus/controllers/dynamic/work-packages/progress/preview.controller.ts b/frontend/src/stimulus/controllers/dynamic/work-packages/progress/preview.controller.ts
index 61b253b88b7d..32c4e625e25e 100644
--- a/frontend/src/stimulus/controllers/dynamic/work-packages/progress/preview.controller.ts
+++ b/frontend/src/stimulus/controllers/dynamic/work-packages/progress/preview.controller.ts
@@ -28,142 +28,20 @@
* ++
*/
-import { Controller } from '@hotwired/stimulus';
-import { debounce, DebouncedFunc } from 'lodash';
-import Idiomorph from 'idiomorph/dist/idiomorph.cjs';
-
-interface TurboBeforeFrameRenderEventDetail {
- render:(currentElement:HTMLElement, newElement:HTMLElement) => void;
-}
-
-interface HTMLTurboFrameElement extends HTMLElement {
- src:string;
-}
-
-export default class PreviewController extends Controller {
- static targets = [
- 'form',
- 'progressInput',
- 'initialValueInput',
- 'touchedFieldInput',
- ];
-
- declare readonly progressInputTargets:HTMLInputElement[];
- declare readonly formTarget:HTMLFormElement;
- declare readonly initialValueInputTargets:HTMLInputElement[];
- declare readonly touchedFieldInputTargets:HTMLInputElement[];
-
- private debouncedPreview:DebouncedFunc<(event:Event) => void>;
- private frameMorphRenderer:(event:CustomEvent) => void;
- private targetFieldName:string;
- private touchedFields:Set;
-
- connect() {
- this.touchedFields = new Set();
- this.touchedFieldInputTargets.forEach((input) => {
- const fieldName = input.dataset.referrerField;
- if (fieldName && input.value === 'true') {
- this.touchedFields.add(fieldName);
- }
- });
-
- this.debouncedPreview = debounce((event:Event) => { void this.preview(event); }, 100);
-
- // Turbo supports morphing, by adding the
- // attribute. However, it does not work that well with primer input: when
- // adding "data-turbo-permanent" to keep value and focus on the active
- // element, it also keeps the `aria-describedby` attribute which references
- // caption and validation element ids. As these elements are morphed and get
- // new ids, the ids referenced by `aria-describedby` are stale. This makes
- // caption and validation message unaccessible for screen readers and other
- // assistive technologies. This is why morph cannot be used here.
- this.frameMorphRenderer = (event:CustomEvent) => {
- event.detail.render = (currentElement:HTMLElement, newElement:HTMLElement) => {
- Idiomorph.morph(currentElement, newElement, { ignoreActiveValue: true });
- };
- };
-
- this.progressInputTargets.forEach((target) => {
- if (target.tagName.toLowerCase() === 'select') {
- target.addEventListener('change', this.debouncedPreview);
- } else {
- target.addEventListener('input', this.debouncedPreview);
- }
- target.addEventListener('blur', this.debouncedPreview);
-
- if (target.dataset.focus === 'true') {
- this.focusAndSetCursorPositionToEndOfInput(target);
- }
- });
-
- const turboFrame = this.formTarget.closest('turbo-frame') as HTMLTurboFrameElement;
- turboFrame.addEventListener('turbo:before-frame-render', this.frameMorphRenderer);
- }
-
- disconnect() {
- this.debouncedPreview.cancel();
- this.progressInputTargets.forEach((target) => {
- if (target.tagName.toLowerCase() === 'select') {
- target.removeEventListener('change', this.debouncedPreview);
- } else {
- target.removeEventListener('input', this.debouncedPreview);
- }
- target.removeEventListener('blur', this.debouncedPreview);
- });
- const turboFrame = this.formTarget.closest('turbo-frame') as HTMLTurboFrameElement;
- if (turboFrame) {
- turboFrame.removeEventListener('turbo:before-frame-render', this.frameMorphRenderer);
- }
- }
+import { DialogPreviewController } from '../dialog/preview.controller';
+export default class PreviewController extends DialogPreviewController {
markFieldAsTouched(event:{ target:HTMLInputElement }) {
- this.targetFieldName = event.target.name.replace(/^work_package\[([^\]]+)\]$/, '$1');
- this.markTouched(this.targetFieldName);
+ super.markFieldAsTouched(event);
if (this.isWorkBasedMode()) {
this.keepWorkValue();
}
}
- async preview(event:Event) {
- let field:HTMLInputElement;
- if (event.type === 'blur') {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
- field = (event as FocusEvent).relatedTarget as HTMLInputElement;
- } else {
- field = event.target as HTMLInputElement;
- }
-
- const form = this.formTarget;
- const formData = new FormData(form) as unknown as undefined;
- const formParams = new URLSearchParams(formData);
-
- const wpParams = Array.from(formParams.entries())
- .filter(([key, _]) => key.startsWith('work_package'));
- wpParams.push(['field', field?.name ?? '']);
-
- const wpPath = this.ensureValidPathname(form.action);
- const wpAction = wpPath.endsWith('/work_packages/new/progress') ? 'new' : 'edit';
-
- const editUrl = `${wpPath}/${wpAction}?${new URLSearchParams(wpParams).toString()}`;
- const turboFrame = this.formTarget.closest('turbo-frame') as HTMLTurboFrameElement;
-
- if (turboFrame) {
- turboFrame.src = editUrl;
- }
- }
-
- private focusAndSetCursorPositionToEndOfInput(field:HTMLInputElement) {
- field.focus();
- field.setSelectionRange(
- field.value.length,
- field.value.length,
- );
- }
-
// Ensures that on create forms, there is an "id" for the un-persisted
// work package when sending requests to the edit action for previews.
- private ensureValidPathname(formAction:string):string {
+ ensureValidPathname(formAction:string):string {
const wpPath = new URL(formAction);
if (wpPath.pathname.endsWith('/work_packages/progress')) {
@@ -174,126 +52,70 @@ export default class PreviewController extends Controller {
return wpPath.toString();
}
+ ensureValidWpAction(wpPath:string):string {
+ return wpPath.endsWith('/work_packages/new/progress') ? 'new' : 'edit';
+ }
+
+ // Inheritance compliance
+ dispatchChangeEvent() {}
+
private isWorkBasedMode() {
- return this.findValueInput('done_ratio') !== undefined;
+ return super.findValueInput('done_ratio') !== undefined;
}
private keepWorkValue() {
- if (this.isInitialValueEmpty('estimated_hours') && !this.isTouched('estimated_hours')) {
+ if (super.isInitialValueEmpty('estimated_hours') && !super.isTouched('estimated_hours')) {
// let work be derived
return;
}
- if (this.isBeingEdited('estimated_hours')) {
+ if (super.isBeingEdited('estimated_hours')) {
this.untouchFieldsWhenWorkIsEdited();
- } else if (this.isBeingEdited('remaining_hours')) {
+ } else if (super.isBeingEdited('remaining_hours')) {
this.untouchFieldsWhenRemainingWorkIsEdited();
- } else if (this.isBeingEdited('done_ratio')) {
+ } else if (super.isBeingEdited('done_ratio')) {
this.untouchFieldsWhenPercentCompleteIsEdited();
}
}
private untouchFieldsWhenWorkIsEdited() {
if (this.areBothTouched('remaining_hours', 'done_ratio')) {
- if (this.isValueEmpty('done_ratio') && this.isValueEmpty('remaining_hours')) {
+ if (super.isValueEmpty('done_ratio') && super.isValueEmpty('remaining_hours')) {
return;
}
- if (this.isValueEmpty('done_ratio')) {
- this.markUntouched('done_ratio');
+ if (super.isValueEmpty('done_ratio')) {
+ super.markUntouched('done_ratio');
} else {
- this.markUntouched('remaining_hours');
+ super.markUntouched('remaining_hours');
}
- } else if (this.isTouchedAndEmpty('remaining_hours') && this.isValueSet('done_ratio')) {
+ } else if (super.isTouchedAndEmpty('remaining_hours') && super.isValueSet('done_ratio')) {
// force remaining work derivation
- this.markUntouched('remaining_hours');
- this.markTouched('done_ratio');
- } else if (this.isTouchedAndEmpty('done_ratio') && this.isValueSet('remaining_hours')) {
+ super.markUntouched('remaining_hours');
+ super.markTouched('done_ratio');
+ } else if (super.isTouchedAndEmpty('done_ratio') && super.isValueSet('remaining_hours')) {
// force % complete derivation
- this.markUntouched('done_ratio');
- this.markTouched('remaining_hours');
+ super.markUntouched('done_ratio');
+ super.markTouched('remaining_hours');
}
}
- private untouchFieldsWhenRemainingWorkIsEdited() {
- if (this.isTouchedAndEmpty('estimated_hours') && this.isValueSet('done_ratio')) {
+ private untouchFieldsWhenRemainingWorkIsEdited():void {
+ if (super.isTouchedAndEmpty('estimated_hours') && super.isValueSet('done_ratio')) {
// force work derivation
- this.markUntouched('estimated_hours');
- this.markTouched('done_ratio');
- } else if (this.isValueSet('estimated_hours')) {
- this.markUntouched('done_ratio');
+ super.markUntouched('estimated_hours');
+ super.markTouched('done_ratio');
+ } else if (super.isValueSet('estimated_hours')) {
+ super.markUntouched('done_ratio');
}
}
- private untouchFieldsWhenPercentCompleteIsEdited() {
- if (this.isValueSet('estimated_hours')) {
- this.markUntouched('remaining_hours');
+ private untouchFieldsWhenPercentCompleteIsEdited():void {
+ if (super.isValueSet('estimated_hours')) {
+ super.markUntouched('remaining_hours');
}
}
- private areBothTouched(fieldName1:string, fieldName2:string) {
- return this.isTouched(fieldName1) && this.isTouched(fieldName2);
- }
-
- private isBeingEdited(fieldName:string) {
- return fieldName === this.targetFieldName;
- }
-
- // Finds the hidden initial value input based on a field name.
- //
- // The initial value input field holds the initial value of the work package
- // before being set by the user or derived.
- private findInitialValueInput(fieldName:string):HTMLInputElement|undefined {
- return this.initialValueInputTargets.find((input) =>
- (input.dataset.referrerField === fieldName));
- }
-
- // Finds the value field input based on a field name.
- //
- // The value field input holds the current value of a progress field.
- private findValueInput(fieldName:string):HTMLInputElement|undefined {
- return this.progressInputTargets.find((input) =>
- (input.name === fieldName) || (input.name === `work_package[${fieldName}]`));
- }
-
- private isTouchedAndEmpty(fieldName:string) {
- return this.isTouched(fieldName) && this.isValueEmpty(fieldName);
- }
-
- private isTouched(fieldName:string) {
- return this.touchedFields.has(fieldName);
- }
-
- private isInitialValueEmpty(fieldName:string) {
- const valueInput = this.findInitialValueInput(fieldName);
- return valueInput?.value === '';
- }
-
- private isValueEmpty(fieldName:string) {
- const valueInput = this.findValueInput(fieldName);
- return valueInput?.value === '';
- }
-
- private isValueSet(fieldName:string) {
- const valueInput = this.findValueInput(fieldName);
- return valueInput !== undefined && valueInput.value !== '';
- }
-
- private markTouched(fieldName:string) {
- this.touchedFields.add(fieldName);
- this.updateTouchedFieldHiddenInputs();
- }
-
- private markUntouched(fieldName:string) {
- this.touchedFields.delete(fieldName);
- this.updateTouchedFieldHiddenInputs();
- }
-
- private updateTouchedFieldHiddenInputs() {
- this.touchedFieldInputTargets.forEach((input) => {
- const fieldName = input.dataset.referrerField;
- if (fieldName) {
- input.value = this.isTouched(fieldName) ? 'true' : 'false';
- }
- });
+ private areBothTouched(fieldName1:string, fieldName2:string):boolean {
+ return super.isTouched(fieldName1) && super.isTouched(fieldName2);
}
}
diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts
index 022ec08d973b..633f29be8c7a 100644
--- a/frontend/src/stimulus/setup.ts
+++ b/frontend/src/stimulus/setup.ts
@@ -14,6 +14,7 @@ import OpShowWhenValueSelectedController from './controllers/show-when-value-sel
import FlashController from './controllers/flash.controller';
import OpProjectsZenModeController from './controllers/dynamic/projects/zen-mode.controller';
import PasswordConfirmationDialogController from './controllers/password-confirmation-dialog.controller';
+import PreviewController from './controllers/dynamic/work-packages/date-picker/preview.controller';
declare global {
interface Window {
@@ -43,3 +44,4 @@ instance.register('show-when-checked', OpShowWhenCheckedController);
instance.register('show-when-value-selected', OpShowWhenValueSelectedController);
instance.register('table-highlighting', TableHighlightingController);
instance.register('projects-zen-mode', OpProjectsZenModeController);
+instance.register('work-packages--date-picker--preview', PreviewController);
diff --git a/lib/primer/open_project/forms/dsl/single_date_picker_input.rb b/lib/primer/open_project/forms/dsl/single_date_picker_input.rb
index 7a356bdfba4c..1337102b5712 100644
--- a/lib/primer/open_project/forms/dsl/single_date_picker_input.rb
+++ b/lib/primer/open_project/forms/dsl/single_date_picker_input.rb
@@ -43,7 +43,7 @@ def initialize(name:, label:, datepicker_options:, **system_arguments)
def derive_datepicker_options(options)
options.reverse_merge(
- component: "opce-single-date-picker"
+ component: "opce-basic-single-date-picker"
)
end
diff --git a/spec/features/work_packages/datepicker/datepicker_logic_spec.rb b/spec/features/work_packages/datepicker/datepicker_logic_spec.rb
index f35f44a3c2fd..5b8ba682e28a 100644
--- a/spec/features/work_packages/datepicker/datepicker_logic_spec.rb
+++ b/spec/features/work_packages/datepicker/datepicker_logic_spec.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
@@ -230,7 +232,9 @@ def apply_and_expect_saved(attributes)
end
end
- describe "when all values set, removing duration through icon (scenario 6a)" do
+ describe "when all values set, removing duration through icon (scenario 6a)",
+ skip: "TODO: the duration field no longer has a 'x' icon to clear the field. " \
+ "If it's not to be added back, we should remove this test." do
let(:current_attributes) do
{
start_date: Date.parse("2021-02-09"),
@@ -244,7 +248,7 @@ def apply_and_expect_saved(attributes)
datepicker.expect_due_date "2021-02-12"
datepicker.expect_duration 3
- datepicker.clear_duration_with_icon
+ datepicker.clear_duration
datepicker.expect_start_date "2021-02-09"
datepicker.expect_due_date ""
@@ -266,7 +270,7 @@ def apply_and_expect_saved(attributes)
datepicker.expect_due_date "2021-02-12"
datepicker.expect_duration 3
- datepicker.clear_duration_with_icon
+ datepicker.clear_duration
datepicker.expect_start_date "2021-02-09"
datepicker.expect_due_date ""
@@ -279,7 +283,7 @@ def apply_and_expect_saved(attributes)
datepicker.expect_due_date "2021-02-09"
datepicker.expect_duration 3
- datepicker.clear_duration_with_icon
+ datepicker.clear_duration
datepicker.expect_start_date "2021-02-05"
datepicker.expect_due_date ""
diff --git a/spec/features/work_packages/details/date_editor_spec.rb b/spec/features/work_packages/details/date_editor_spec.rb
index ca273c164bf4..3df939b62065 100644
--- a/spec/features/work_packages/details/date_editor_spec.rb
+++ b/spec/features/work_packages/details/date_editor_spec.rb
@@ -64,6 +64,7 @@
work_packages_page.visit!
work_packages_page.ensure_page_loaded
+ wait_for_network_idle
end
it "can directly set the due date when only a start date is set" do
@@ -139,6 +140,9 @@
start_date.datepicker.expect_start_date "2016-01-03"
+ # The inputs have a debounce which we have to wait for before clicking the next field
+ sleep 0.25
+
# Since the focus shifts automatically, we can directly click again to modify the end date
start_date.datepicker.select_day "21"
@@ -216,7 +220,7 @@
start_date.set_due_date Time.zone.today
# Wait for duration to be derived
- start_date.expect_duration /\d+ days/
+ start_date.expect_duration /\d+/
# As the to be selected date is automatically toggled,
# we can directly set the start date afterwards to the same day
@@ -270,9 +274,10 @@
work_packages_page.accept_alert_dialog! if work_packages_page.has_alert_dialog?
# Ensure no modal survives
- expect(page).to have_no_css(".spot-modal")
+ expect(page).to have_no_css(".spot-drop-modal--body")
end
+ # rubocop:disable Layout/LineLength
context "with a date custom field" do
let(:cf_field) { EditField.new page, date_cf.attribute_name(:camel_case) }
let(:datepicker) { Components::BasicDatepicker.new }
@@ -352,17 +357,16 @@
let(:schedule_manually) { true }
it "shows a banner that the relations are ignored" do
- expect(page).to have_css("#{test_selector('op-modal-banner-warning')} span",
- text: "Manual scheduling enabled, all relations ignored.",
+ expect(page).to have_css(test_selector("op-modal-banner-warning").to_s,
+ text: "Manually scheduled. Dates not affected by relations. Click on “Show relations” for Gantt overview.",
wait: 5)
# When toggling manually scheduled
start_date.toggle_scheduling_mode
- # Expect new banner info
- expect(page)
- .to have_css("#{test_selector('op-modal-banner-warning')} span",
- text: "Changing these dates will affect dates of related work packages.")
+ # Expect no banner as it is not automatically schedulable
+ expect(page).not_to have_test_selector("op-modal-banner-warning")
+ expect(page).not_to have_test_selector("op-modal-banner-info")
new_window = window_opened_by { click_on "Show relations" }
switch_to_window new_window
@@ -378,17 +382,15 @@
context "when work package is not manually scheduled" do
let(:schedule_manually) { false }
- it "shows a banner that the start date is limited" do
- expect(page)
- .to have_css("#{test_selector('op-modal-banner-warning')} span",
- text: "Changing these dates will affect dates of related work packages.",
- wait: 5)
+ it "shows no banner as the WP is not automatically savable without children or predecessor" do
+ expect(page).not_to have_test_selector("op-modal-banner-warning")
+ expect(page).not_to have_test_selector("op-modal-banner-info")
# When toggling manually scheduled
start_date.toggle_scheduling_mode
- expect(page).to have_css("#{test_selector('op-modal-banner-warning')} span",
- text: "Manual scheduling enabled, all relations ignored.")
+ expect(page).to have_css(test_selector("op-modal-banner-warning").to_s,
+ text: "Manually scheduled. Dates not affected by relations. Click on “Show relations” for Gantt overview.")
end
end
end
@@ -410,15 +412,15 @@
let(:schedule_manually) { true }
it "shows a banner that the relations are ignored" do
- expect(page).to have_css("#{test_selector('op-modal-banner-warning')} span",
- text: "Manual scheduling enabled, all relations ignored.")
+ expect(page).to have_css(test_selector("op-modal-banner-warning").to_s,
+ text: "Manually scheduled. Dates not affected by relations. This has child work packages but their start dates are ignored.")
# When toggling manually scheduled
start_date.toggle_scheduling_mode
# Expect banner to switch
- expect(page).to have_css("#{test_selector('op-modal-banner-info')} span",
- text: "Automatically scheduled. Dates are derived from relations.")
+ expect(page).to have_css(test_selector("op-modal-banner-info").to_s,
+ text: "The dates are determined by child work packages. Click on “Show relations” for Gantt overview.")
new_window = window_opened_by { click_on "Show relations" }
switch_to_window new_window
@@ -432,15 +434,15 @@
context "when parent is not manually scheduled" do
let(:schedule_manually) { false }
- it "shows a banner that the dates are not editable" do
- expect(page).to have_css("#{test_selector('op-modal-banner-info')} span",
- text: "Automatically scheduled. Dates are derived from relations.")
+ it "shows a banner that the dates are are determined by the child" do
+ expect(page).to have_css(test_selector("op-modal-banner-info").to_s,
+ text: "The dates are determined by child work packages. Click on “Show relations” for Gantt overview.")
# When toggling manually scheduled
start_date.toggle_scheduling_mode
- expect(page).to have_css("#{test_selector('op-modal-banner-warning')} span",
- text: "Manual scheduling enabled, all relations ignored.")
+ expect(page).to have_css(test_selector("op-modal-banner-warning").to_s,
+ text: "Manually scheduled. Dates not affected by relations. This has child work packages but their start dates are ignored.")
new_window = window_opened_by { click_on "Show relations" }
switch_to_window new_window
@@ -460,8 +462,8 @@
end
it "allows switching to manual scheduling to set the ignore NWD (Regression #43933)" do
- expect(page).to have_css("#{test_selector('op-modal-banner-info')} span",
- text: "Automatically scheduled. Dates are derived from relations.")
+ expect(page).to have_css(test_selector("op-modal-banner-info").to_s,
+ text: "The dates are determined by child work packages. Click on “Show relations” for Gantt overview.")
# Expect "Working days only" to be checked
datepicker.expect_ignore_non_working_days_disabled
@@ -473,16 +475,16 @@
datepicker.toggle_ignore_non_working_days
datepicker.expect_ignore_non_working_days true
- expect(page).to have_css("#{test_selector('op-modal-banner-warning')} span",
- text: "Manual scheduling enabled, all relations ignored.")
+ expect(page).to have_css(test_selector("op-modal-banner-warning").to_s,
+ text: "Manually scheduled. Dates not affected by relations. This has child work packages but their start dates are ignored.")
# Reset when disabled
start_date.toggle_scheduling_mode
datepicker.expect_ignore_non_working_days_disabled
datepicker.expect_ignore_non_working_days false, disabled: true
- expect(page).to have_css("#{test_selector('op-modal-banner-info')} span",
- text: "Automatically scheduled. Dates are derived from relations.")
+ expect(page).to have_css(test_selector("op-modal-banner-info").to_s,
+ text: "The dates are determined by child work packages. Click on “Show relations” for Gantt overview.")
end
end
end
@@ -508,15 +510,15 @@
let(:schedule_manually) { true }
it "shows a banner that the relations are ignored" do
- expect(page).to have_css("#{test_selector('op-modal-banner-warning')} span",
- text: "Manual scheduling enabled, all relations ignored.")
+ expect(page).to have_css(test_selector("op-modal-banner-warning").to_s,
+ text: "Manually scheduled. Dates not affected by relations. Click on “Show relations” for Gantt overview.")
# When toggling manually scheduled
start_date.toggle_scheduling_mode
# Expect new banner info
- expect(page).to have_css("#{test_selector('op-modal-banner-info')} span",
- text: "Available start and finish dates are limited by relations.")
+ expect(page).to have_css(test_selector("op-modal-banner-info").to_s,
+ text: "The start date is set by a predecessor. Click on “Show relations” for Gantt overview.")
new_window = window_opened_by { click_on "Show relations" }
switch_to_window new_window
@@ -530,15 +532,15 @@
context "when work package is not manually scheduled" do
let(:schedule_manually) { false }
- it "shows a banner that the start date is limited" do
- expect(page).to have_css("#{test_selector('op-modal-banner-info')} span",
- text: "Available start and finish dates are limited by relations.")
+ it "shows a banner that the start date it set by the predecessor" do
+ expect(page).to have_css(test_selector("op-modal-banner-info").to_s,
+ text: "The start date is set by a predecessor. Click on “Show relations” for Gantt overview.")
# When toggling manually scheduled
start_date.toggle_scheduling_mode
- expect(page).to have_css("#{test_selector('op-modal-banner-warning')} span",
- text: "Manual scheduling enabled, all relations ignored.")
+ expect(page).to have_css(test_selector("op-modal-banner-warning").to_s,
+ text: "Manually scheduled. Dates not affected by relations. Click on “Show relations” for Gantt overview.")
end
end
end
@@ -563,15 +565,15 @@
let(:schedule_manually) { true }
it "shows a banner that the relations are ignored" do
- expect(page).to have_css("#{test_selector('op-modal-banner-warning')} span",
- text: "Manual scheduling enabled, all relations ignored.")
+ expect(page).to have_css(test_selector("op-modal-banner-warning").to_s,
+ text: "Manually scheduled. Dates not affected by relations. Click on “Show relations” for Gantt overview.")
# When toggling manually scheduled
start_date.toggle_scheduling_mode
- expect(page)
- .to have_css("#{test_selector('op-modal-banner-warning')} span",
- text: "Changing these dates will affect dates of related work packages.")
+ # There is no banner
+ expect(page).not_to have_test_selector("op-modal-banner-warning")
+ expect(page).not_to have_test_selector("op-modal-banner-info")
new_window = window_opened_by { click_on "Show relations" }
switch_to_window new_window
@@ -585,19 +587,20 @@
context "when work package is not manually scheduled" do
let(:schedule_manually) { false }
- it "shows a banner that the start date is limited" do
- expect(page)
- .to have_css("#{test_selector('op-modal-banner-warning')} span",
- text: "Changing these dates will affect dates of related work packages.")
+ it "shows no banner as the WP is not automatically savable without children or predecessor" do
+ # There is no banner
+ expect(page).not_to have_test_selector("op-modal-banner-warning")
+ expect(page).not_to have_test_selector("op-modal-banner-info")
# When toggling manually scheduled
start_date.toggle_scheduling_mode
- expect(page).to have_css("#{test_selector('op-modal-banner-warning')} span",
- text: "Manual scheduling enabled, all relations ignored.")
+ expect(page).to have_css(test_selector("op-modal-banner-warning").to_s,
+ text: "Manually scheduled. Dates not affected by relations. Click on “Show relations” for Gantt overview.")
end
end
end
+ # rubocop:enable Layout/LineLength
context "with a negative time zone", driver: :chrome_new_york_time_zone do
it "can normally select the dates via datepicker (regression #43562)" do
diff --git a/spec/features/work_packages/table/duration_field_spec.rb b/spec/features/work_packages/table/duration_field_spec.rb
index 38fc4268b96a..8d6d13fa21a9 100644
--- a/spec/features/work_packages/table/duration_field_spec.rb
+++ b/spec/features/work_packages/table/duration_field_spec.rb
@@ -35,9 +35,10 @@
it "shows the duration as days and opens the datepicker on click" do
duration.expect_state_text "4 days"
duration.activate!
+ wait_for_network_idle
date_field.expect_duration_highlighted
- expect(page).to have_focus_on("#{test_selector('op-datepicker-modal--duration-field')} input[name='duration']")
- expect(page).to have_field("duration", with: "4", wait: 10)
+ expect(page).to have_focus_on(test_selector("op-datepicker-modal--duration-field").to_s)
+ expect(page).to have_field("work_package[duration]", with: "4", wait: 10)
end
end
diff --git a/spec/features/work_packages/table/scheduling/manual_scheduling_spec.rb b/spec/features/work_packages/table/scheduling/manual_scheduling_spec.rb
index bc60351eee80..025753a1b258 100644
--- a/spec/features/work_packages/table/scheduling/manual_scheduling_spec.rb
+++ b/spec/features/work_packages/table/scheduling/manual_scheduling_spec.rb
@@ -56,8 +56,8 @@
# Expect not editable
start_date.within_modal do
- expect(page).to have_css('input[name="startDate"][disabled]')
- expect(page).to have_css('input[name="endDate"][disabled]')
+ expect(page).to have_css('input[name="work_package[start_date]"][disabled]')
+ expect(page).to have_css('input[name="work_package[due_date]"][disabled]')
expect(page).to have_css("#{test_selector('op-datepicker-modal--action')}:not([disabled])", text: "Cancel")
expect(page).to have_css("#{test_selector('op-datepicker-modal--action')}:not([disabled])", text: "Save")
end
@@ -66,8 +66,8 @@
# Expect editable
start_date.within_modal do
- expect(page).to have_css('input[name="startDate"]:not([disabled])')
- expect(page).to have_css('input[name="endDate"]:not([disabled])')
+ expect(page).to have_css('input[name="work_package[start_date]"]:not([disabled])')
+ expect(page).to have_css('input[name="work_package[due_date]"]:not([disabled])')
expect(page).to have_css("#{test_selector('op-datepicker-modal--action')}:not([disabled])", text: "Cancel")
expect(page).to have_css("#{test_selector('op-datepicker-modal--action')}:not([disabled])", text: "Save")
end
@@ -90,8 +90,8 @@
# Expect not editable
start_date.within_modal do
- expect(page).to have_css("input[name=startDate][disabled]")
- expect(page).to have_css("input[name=endDate][disabled]")
+ expect(page).to have_css('input[name="work_package[start_date]"][disabled]')
+ expect(page).to have_css('input[name="work_package[due_date]"][disabled]')
expect(page).to have_css("#{test_selector('op-datepicker-modal--action')}:not([disabled])", text: "Cancel")
expect(page).to have_css("#{test_selector('op-datepicker-modal--action')}:not([disabled])", text: "Save")
end
@@ -101,8 +101,8 @@
# Expect not editable
start_date.within_modal do
- fill_in "startDate", with: "2020-07-20"
- fill_in "endDate", with: "2020-07-25"
+ fill_in "work_package[start_date]", with: "2020-07-20"
+ fill_in "work_package[due_date]", with: "2020-07-25"
end
# Wait for the debounce to be done
diff --git a/spec/support/components/datepicker/datepicker.rb b/spec/support/components/datepicker/datepicker.rb
index 01ef8d1f311b..a8e46495d799 100644
--- a/spec/support/components/datepicker/datepicker.rb
+++ b/spec/support/components/datepicker/datepicker.rb
@@ -39,16 +39,21 @@ def flatpickr_container
##
# Clear all values
def clear!
- set_field(container.find_field("startDate"), "", wait_for_changes_to_be_applied: false)
- set_field(container.find_field("endDate"), "", wait_for_changes_to_be_applied: false)
+ set_field(container.find_field("work_package[start_date]"), "", wait_for_changes_to_be_applied: false)
+ set_field(container.find_field("work_package[due_date]"), "", wait_for_changes_to_be_applied: false)
end
def expect_visible
expect(container).to have_css(".flatpickr-calendar .flatpickr-current-month", wait: 10)
+
+ # For whatever reason, the stimulus controller in the WorkPackage Datepicker needs some time to be loaded.
+ # So please, do not remove this line.
+ wait_for_network_idle
end
def expect_not_visible
expect(container).to have_no_css(".flatpickr-calendar .flatpickr-current-month", wait: 10)
+ wait_for_network_idle
end
##
diff --git a/spec/support/components/datepicker/work_package_datepicker.rb b/spec/support/components/datepicker/work_package_datepicker.rb
index a00c759ae77e..d731f125047d 100644
--- a/spec/support/components/datepicker/work_package_datepicker.rb
+++ b/spec/support/components/datepicker/work_package_datepicker.rb
@@ -20,28 +20,23 @@ def expect_month(month)
##
# Expect duration
def expect_duration(value)
- value =
- if value.is_a?(Regexp)
- value
- elsif value.nil? || value == ""
- ""
- else
- I18n.t("js.units.day", count: value)
- end
+ if value.nil? || value == ""
+ value = ""
+ end
- expect(container).to have_field("duration", with: value, wait: 10)
+ expect(container).to have_field("work_package[duration]", with: value, wait: 10)
end
def milestone_date_field
- container.find_field "date"
+ container.find_field "work_package[start_date]"
end
def start_date_field
- container.find_field "startDate"
+ container.find_field "work_package[start_date]"
end
def due_date_field
- container.find_field "endDate"
+ container.find_field "work_package[due_date]"
end
def focus_milestone_date
@@ -59,19 +54,19 @@ def focus_due_date
##
# Expect date (milestone type)
def expect_milestone_date(value)
- expect(container).to have_field("date", with: value, wait: 20)
+ expect(container).to have_field("work_package[start_date]", with: value, wait: 20)
end
##
# Expect start date
def expect_start_date(value)
- expect(container).to have_field("startDate", with: value, wait: 20)
+ expect(container).to have_field("work_package[start_date]", with: value, wait: 20)
end
##
# Expect due date
def expect_due_date(value)
- expect(container).to have_field("endDate", with: value, wait: 20)
+ expect(container).to have_field("work_package[due_date]", with: value, wait: 20)
end
def set_milestone_date(value)
@@ -91,11 +86,11 @@ def expect_start_highlighted
end
def expect_due_highlighted
- expect(container).to have_css('[data-test-selector="op-datepicker-modal--end-date-field"][data-qa-highlighted]')
+ expect(container).to have_css('[data-test-selector="op-datepicker-modal--due-date-field"][data-qa-highlighted]')
end
def duration_field
- container.find_field "duration"
+ container.find_field "work_package[duration]"
end
def focus_duration
@@ -103,17 +98,7 @@ def focus_duration
end
def set_today(date)
- key =
- case date.to_s
- when "due"
- "end"
- else
- date
- end
-
- page.within("[data-test-selector='datepicker-#{key}-date']") do
- find("button", text: "Today").click
- end
+ page.find_test_selector("op-datepicker-modal--#{date}-date-field--today").click
end
def save!(text: I18n.t(:button_save))
@@ -140,38 +125,38 @@ def expect_scheduling_mode(manually)
end
def expect_manual_scheduling_mode
- expect(container).to have_checked_field("scheduling", visible: :all)
+ expect(container)
+ .to have_css('[data-test-selector="op-datepicker-modal--scheduling_manual"][data-qa-selected="true"]')
end
def expect_automatic_scheduling_mode
- expect(container).to have_unchecked_field("scheduling", visible: :all)
+ expect(container)
+ .to have_css('[data-test-selector="op-datepicker-modal--scheduling_automatic"][data-qa-selected="true"]')
end
def toggle_scheduling_mode
- find("label", text: "Manual scheduling").click
- end
-
- def scheduling_mode_input
- container.find_field "scheduling", visible: :all
- end
-
- def ignore_non_working_days_input
- container.find_field "weekdays_only", visible: :all
+ page.within_test_selector "op-datepicker-modal--scheduling" do
+ page.find('[data-qa-selected="false"]').click
+ end
end
def expect_ignore_non_working_days_disabled
- expect(container).to have_field("weekdays_only", disabled: true)
+ expect(container)
+ .to have_field("work_package[ignore_non_working_days]", disabled: true)
end
def expect_ignore_non_working_days_enabled
- expect(container).to have_field("weekdays_only", disabled: false)
+ expect(container)
+ .to have_field("work_package[ignore_non_working_days]", disabled: false)
end
def expect_ignore_non_working_days(val, disabled: false)
if val
- expect(container).to have_unchecked_field("weekdays_only", disabled:)
+ expect(container)
+ .to have_field("work_package[ignore_non_working_days]", checked: false, disabled:)
else
- expect(container).to have_checked_field("weekdays_only", disabled:)
+ expect(container)
+ .to have_field("work_package[ignore_non_working_days]", checked: true, disabled:)
end
end
@@ -182,13 +167,5 @@ def toggle_ignore_non_working_days
def clear_duration
set_duration("")
end
-
- def clear_duration_with_icon
- duration_field.click
-
- page
- .find('[data-test-selector="op-datepicker-modal--duration-field"] .spot-text-field--clear-button')
- .click
- end
end
end
diff --git a/spec/support/edit_fields/date_edit_field.rb b/spec/support/edit_fields/date_edit_field.rb
index 0fb84dfa5b36..ff395de07b48 100644
--- a/spec/support/edit_fields/date_edit_field.rb
+++ b/spec/support/edit_fields/date_edit_field.rb
@@ -42,9 +42,9 @@ def modal_selector
def input_selector
if property_name == "combinedDate"
- "input[name=startDate]"
+ "input[name='work_package[start_date]']"
else
- "input[name=#{property_name}]"
+ "input[name='work_package[#{property_name.underscore}]']"
end
end
@@ -77,7 +77,7 @@ def activate_start_date_within_modal
def activate_due_date_within_modal
within_modal do
- find('[data-test-selector="op-datepicker-modal--end-date-field"]').click
+ find('[data-test-selector="op-datepicker-modal--due-date-field"]').click
end
end
@@ -111,6 +111,8 @@ def expect_active!
expect(page)
.to have_selector(modal_selector, wait: 10),
"Expected date field '#{property_name}' to be active."
+
+ wait_for_network_idle
end
def expect_inactive!
diff --git a/spec/support/edit_fields/edit_field.rb b/spec/support/edit_fields/edit_field.rb
index a07e627e94d2..9b091522d3e1 100644
--- a/spec/support/edit_fields/edit_field.rb
+++ b/spec/support/edit_fields/edit_field.rb
@@ -156,6 +156,7 @@ def expect_active!
# Also ensure the element is not disabled
expect_enabled!
+ wait_for_network_idle
end
def expect_inactive!
diff --git a/spec/support/edit_fields/progress_edit_field.rb b/spec/support/edit_fields/progress_edit_field.rb
index 7ce954d4c84e..17fc5e4e22d7 100644
--- a/spec/support/edit_fields/progress_edit_field.rb
+++ b/spec/support/edit_fields/progress_edit_field.rb
@@ -83,6 +83,7 @@ def active?
end
def clear
+ move_caret_to_end_of_input
super(with_backspace: true)
end
@@ -110,15 +111,16 @@ def status_field?
def focus
return if focused?
- input_element.click
- input_element.click if status_field? # to close the dropdown
+ page.evaluate_script("arguments[0].focus()", input_element)
wait_for_preview_to_complete
end
# Wait for the popover preview to be refreshed.
# Preview occurs on field blur or change.
def wait_for_preview_to_complete
- sleep 0.110 # the preview on popover has a debounce of 100ms
+ # The preview on popover has a debounce that must be kept in sync here.
+ # See frontend/src/stimulus/controllers/dynamic/work-packages/dialog/preview.controller.ts
+ sleep 0.210
if using_cuprite?
wait_for_network_idle # Wait for preview to finish
end
@@ -208,6 +210,10 @@ def cursor_at_end_of_input?
input_element.evaluate_script("this.selectionStart == this.value.length;")
end
+ def move_caret_to_end_of_input
+ page.evaluate_script("arguments[0].setSelectionRange(arguments[0].value.length, arguments[0].value.length)", input_element)
+ end
+
def expect_trigger_field_disabled
expect(trigger_element).to be_disabled
end