,
+ @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;
}
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..da2bc2bbb2d6
--- /dev/null
+++ b/frontend/src/app/shared/components/fields/edit/modal-with-turbo-content/modal-with-turbo-content.directive.ts
@@ -0,0 +1,143 @@
+//-- 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
+ .addEventListener('turbo:submit-end', this.contextBasedListener.bind(this));
+
+ document
+ .addEventListener('cancelModalWithTurboContent', this.cancelListener.bind(this));
+ }
+
+ ngOnDestroy() {
+ this
+ .elementRef
+ .nativeElement
+ .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 39f0b5f71e30..dfff0f97a659 100644
--- a/frontend/src/app/shared/components/fields/openproject-fields.module.ts
+++ b/frontend/src/app/shared/components/fields/openproject-fields.module.ts
@@ -107,6 +107,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: [
@@ -171,6 +172,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/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass
index 2ef53fc4dd50..8c58cd2161fd 100644
--- a/frontend/src/global_styles/primer/_overrides.sass
+++ b/frontend/src/global_styles/primer/_overrides.sass
@@ -44,6 +44,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..85091887cb9c
--- /dev/null
+++ b/frontend/src/stimulus/controllers/dynamic/work-packages/date-picker/preview.controller.ts
@@ -0,0 +1,73 @@
+/*
+ * -- 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';
+
+export default class PreviewController extends DialogPreviewController {
+ 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/date_picker')) {
+ // Replace /work_packages/date_picker with /work_packages/new/date_picker
+ wpPath.pathname = wpPath.pathname.replace('/work_packages/date_picker', '/work_packages/new/date_picker');
+ }
+
+ return wpPath.toString();
+ }
+
+ ensureValidWpAction(wpPath:string):string {
+ return wpPath.endsWith('/work_packages/new/date_picker') ? '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;
+ }
+}
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..f51d4964e7c8
--- /dev/null
+++ b/frontend/src/stimulus/controllers/dynamic/work-packages/dialog/preview.controller.ts
@@ -0,0 +1,243 @@
+/*
+ * -- 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);
+ }
+ });
+
+ 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, newNode: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/lookbook/previews/open_project/common/datepicker_preview/single.html.erb b/lookbook/previews/open_project/common/datepicker_preview/single.html.erb
index bd6afbdcdc15..70df33c0ebb0 100644
--- a/lookbook/previews/open_project/common/datepicker_preview/single.html.erb
+++ b/lookbook/previews/open_project/common/datepicker_preview/single.html.erb
@@ -1 +1 @@
-<%= tag :'opce-single-date-picker', value: %>
+<%= tag :'opce-basic-single-date-picker', value: %>