From 7d8bfa55f8fbb9b2ef7164521c35cf017bd34d09 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Wed, 4 Dec 2024 13:52:05 +0100 Subject: [PATCH 01/92] Start on using Primer components inside the datepicker modal --- .../date_picker/banner_component.html.erb | 11 ++ .../date_picker/banner_component.rb | 122 ++++++++++++++++++ .../dialog_content_component.html.erb | 64 +++++++++ .../date_picker/dialog_content_component.rb | 52 ++++++++ app/controllers/work_packages_controller.rb | 28 +++- .../datepicker_dialog_content.html.erb | 5 + config/locales/en.yml | 19 +++ config/locales/js-en.yml | 9 -- config/routes.rb | 1 + .../core/path-helper/path-helper.service.ts | 4 + .../datepicker/datepicker.module.ts | 2 + .../wp-multi-date-form.component.html | 47 ++++--- .../wp-multi-date-form.component.ts | 6 + 13 files changed, 340 insertions(+), 30 deletions(-) create mode 100644 app/components/work_packages/date_picker/banner_component.html.erb create mode 100644 app/components/work_packages/date_picker/banner_component.rb create mode 100644 app/components/work_packages/date_picker/dialog_content_component.html.erb create mode 100644 app/components/work_packages/date_picker/dialog_content_component.rb create mode 100644 app/views/work_packages/datepicker_dialog_content.html.erb diff --git a/app/components/work_packages/date_picker/banner_component.html.erb b/app/components/work_packages/date_picker/banner_component.html.erb new file mode 100644 index 000000000000..cc1ad5083ec5 --- /dev/null +++ b/app/components/work_packages/date_picker/banner_component.html.erb @@ -0,0 +1,11 @@ +<%= + render(Primer::Alpha::Banner.new(scheme:, + full: true, + icon: :info, + description:, + mb: 3, + classes: "rounded-top-2")) do |banner| + banner.with_action_button(tag: :a, href: link, target: "_blank") { I18n.t("work_packages.datepicker_modal.show_relations") } + title + end +%> diff --git a/app/components/work_packages/date_picker/banner_component.rb b/app/components/work_packages/date_picker/banner_component.rb new file mode 100644 index 000000000000..7a1d2f70626a --- /dev/null +++ b/app/components/work_packages/date_picker/banner_component.rb @@ -0,0 +1,122 @@ +#-- 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. +#++ + +# frozen_string_literal: true + +module WorkPackages + module DatePicker + class BannerComponent < ApplicationComponent + def initialize(work_package:, manually_scheduled: true) + super + + @work_package = work_package + @manually_scheduled = manually_scheduled + end + + private + + def scheme + @manually_scheduled ? :warning : :default + end + + def link + gantt_index_path( + query_props: { + c: %w[id subject type status assignee project startDate dueDate], + tll: '{"left":"startDate","right":"subject","farRight":null}', + tzl: "auto", + t: "id:asc", + tv: true, + hi: true, + f: [ + { "n" => "id", "o" => "=", "v" => all_relational_wp_ids } + ] + }.to_json.freeze + ) + end + + def title + if @manually_scheduled + I18n.t("work_packages.datepicker_modal.banner.title.manually_scheduled") + elsif children.any? + I18n.t("work_packages.datepicker_modal.banner.title.automatic_with_children") + elsif predecessor_relations.any? + I18n.t("work_packages.datepicker_modal.banner.title.automatic_with_predecessor") + end + end + + def description + if @manually_scheduled + if children.any? + return I18n.t("work_packages.datepicker_modal.banner.description.manual_with_children") + elsif predecessor_relations.any? + if overlapping_predecessor? + return I18n.t("work_packages.datepicker_modal.banner.description.manual_overlap_with_predecessors") + elsif predecessor_with_large_gap? + return I18n.t("work_packages.datepicker_modal.banner.description.manual_gap_between_predecessors") + end + end + end + + I18n.t("work_packages.datepicker_modal.banner.description.click_on_show_relations_to_open_gantt", + button_name: I18n.t("work_packages.datepicker_modal.show_relations")) + end + + def overlapping_predecessor? + predecessor_work_packages.any? { |wp| wp.due_date.after?(@work_package.start_date) } + end + + def predecessor_with_large_gap? + sorted = predecessor_work_packages.sort_by(&:due_date) + sorted.last.due_date.before?(@work_package.start_date - 2) + end + + def predecessor_relations + @predecessor_relations ||= @work_package.follows_relations + end + + def predecessor_work_packages + @predecessor_work_packages ||= predecessor_relations + .includes(:to) + .map(&:to) + end + + def children + @children ||= @work_package.children + end + + def all_relational_wp_ids + @work_package + .relations + .pluck(:from_id, :to_id) + .flatten + .uniq + end + end + end +end diff --git a/app/components/work_packages/date_picker/dialog_content_component.html.erb b/app/components/work_packages/date_picker/dialog_content_component.html.erb new file mode 100644 index 000000000000..a60a86b44706 --- /dev/null +++ b/app/components/work_packages/date_picker/dialog_content_component.html.erb @@ -0,0 +1,64 @@ +<%= + component_wrapper do + component_collection do |collection| + if show_banner? + collection.with_component(WorkPackages::DatePicker::BannerComponent.new(work_package:, manually_scheduled:)) + end + + collection.with_component(Primer::Alpha::Dialog::Body.new) do + flex_layout do |body| + body.with_row(mb: 3) do + flex_layout(align_items: :flex_end, justify_content: :space_between) do |first_row| + first_row.with_column do + render(Primer::Alpha::FormControl.new(label: I18n.t("work_packages.datepicker_modal.mode.title"))) do |component| + component.with_input do + render(Primer::Alpha::SegmentedControl.new("aria-label": I18n.t("work_packages.datepicker_modal.mode.title"))) do |control| + control.with_item(tag: :a, + href: datepicker_dialog_content_work_package_path(manually_scheduled: true), + data: { turbo_stream: true }, + label: I18n.t("work_packages.datepicker_modal.mode.manual"), + title: I18n.t("work_packages.datepicker_modal.mode.manual"), + selected: manually_scheduled) + control.with_item(tag: :a, + href: datepicker_dialog_content_work_package_path(manually_scheduled: false), + data: { turbo_stream: true }, + label: I18n.t("work_packages.datepicker_modal.mode.automatic"), + title: I18n.t("work_packages.datepicker_modal.mode.automatic"), + selected: !manually_scheduled) + end + end + end + end + + first_row.with_column(mb: 1) do + render(Primer::Alpha::CheckBox.new(name: "name", + label: I18n.t("work_packages.datepicker_modal.ignore_non_working_days.title"), + checked: work_package.ignore_non_working_days)) + end + end + end + + body.with_row(mb: 3) do + flex_layout(justify_content: :space_between, gap: 3) do |second_row| + second_row.with_column do + render(Primer::Alpha::TextField.new(name: :start_date, + label: I18n.t("attributes.start_date"), + value: work_package.start_date)) + end + second_row.with_column do + render(Primer::Alpha::TextField.new(name: :due_date, + label: I18n.t("attributes.due_date"), + value: work_package.due_date)) + end + second_row.with_column do + render(Primer::Alpha::TextField.new(name: :duration, + label: I18n.t("activerecord.attributes.work_package.duration"), + value: work_package.duration)) + end + end + end + end + end + end + end +%> diff --git a/app/components/work_packages/date_picker/dialog_content_component.rb b/app/components/work_packages/date_picker/dialog_content_component.rb new file mode 100644 index 000000000000..338b89310e2d --- /dev/null +++ b/app/components/work_packages/date_picker/dialog_content_component.rb @@ -0,0 +1,52 @@ +#-- 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. +#++ + +# frozen_string_literal: true +module WorkPackages + module DatePicker + class DialogContentComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + attr_accessor :work_package, :manually_scheduled + + def initialize(work_package:, manually_scheduled: true) + super + + @work_package = work_package + @manually_scheduled = ActiveModel::Type::Boolean.new.cast(manually_scheduled) + end + + private + + def show_banner? + true # TODO + end + end + end +end diff --git a/app/controllers/work_packages_controller.rb b/app/controllers/work_packages_controller.rb index 36e8a12f8a15..5e18a58ca21b 100644 --- a/app/controllers/work_packages_controller.rb +++ b/app/controllers/work_packages_controller.rb @@ -38,12 +38,13 @@ class WorkPackagesController < ApplicationController before_action :authorize_on_work_package, :project, only: %i[show generate_pdf_dialog generate_pdf] + before_action :authorize_on_work_package, only: %i[datepicker_dialog_content] before_action :load_and_authorize_in_optional_project, :check_allowed_export, :protect_from_unauthorized_export, only: %i[index export_dialog] before_action :authorize, only: :show_conflict_flash_message - authorization_checked! :index, :show, :export_dialog, :generate_pdf_dialog, :generate_pdf + authorization_checked! :index, :show, :export_dialog, :generate_pdf_dialog, :generate_pdf, :datepicker_dialog_content before_action :load_and_validate_query, only: :index, unless: -> { request.format.html? } before_action :load_work_packages, only: :index, if: -> { request.format.atom? } @@ -106,6 +107,29 @@ def generate_pdf redirect_back(fallback_location: work_package_path(work_package)) end + def datepicker_dialog_content + manually_scheduled = if params[:manually_scheduled].present? + params.delete(:manually_scheduled) + else + work_package.schedule_manually + end + + respond_to do |format| + format.html do + render :datepicker_dialog_content, + locals: { work_package:, manually_scheduled:, params: }, + layout: false + end + + format.turbo_stream do + replace_via_turbo_stream( + component: WorkPackages::DatePicker::DialogContentComponent.new(work_package:, manually_scheduled:) + ) + render turbo_stream: turbo_streams + end + end + end + def show_conflict_flash_message scheme = params[:scheme]&.to_sym || :danger @@ -174,7 +198,7 @@ def per_page_param end def project - @project ||= work_package ? work_package.project : nil + @project ||= work_package&.project end def work_package diff --git a/app/views/work_packages/datepicker_dialog_content.html.erb b/app/views/work_packages/datepicker_dialog_content.html.erb new file mode 100644 index 000000000000..41f3b4acece1 --- /dev/null +++ b/app/views/work_packages/datepicker_dialog_content.html.erb @@ -0,0 +1,5 @@ +<%= + content_tag("turbo-frame", id: "wp-datepicker-dialog--content") do + render(WorkPackages::DatePicker::DialogContentComponent.new(work_package:, manually_scheduled:)) + end +%> diff --git a/config/locales/en.yml b/config/locales/en.yml index 6d55d3aeb465..a8a3f7b95af7 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -693,6 +693,25 @@ en: no_results_title_text: There are currently no workflows. work_packages: + datepicker_modal: + banner: + description: + click_on_show_relations_to_open_gantt: 'Click on "%{button_name}" for Gantt overview.' + manual_gap_between_predecessors: "There is a gap between this and all predecessors." + manual_overlap_with_predecessors: "Overlaps with at least one predecessor." + manual_with_children: "This has child work package but their start dates are ignored." + title: + automatic_with_children: "The dates are determined by child work packages." + automatic_with_predecessor: "The start date is set by a predecessor." + manually_scheduled: "Manually scheduled. Dates not affected by relations." + ignore_non_working_days: + title: "Working days only" + mode: + title: "Scheduling mode" + automatic: "Automatic" + manual: "Manual" + show_relations: "Show relations" + x_descendants: one: "One descendant work package" other: "%{count} work package descendants" diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index a73a2f719f12..0427cb1e6a92 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -983,15 +983,6 @@ en: comment_send_failed: "An error has occurred. Could not submit the comment." comment_updated: "The comment was successfully updated." confirm_edit_cancel: "Are you sure you want to cancel editing the work package?" - datepicker_modal: - automatically_scheduled_parent: "Automatically scheduled. Dates are derived from relations." - manually_scheduled: "Manual scheduling enabled, all relations ignored." - start_date_limited_by_relations: "Available start and finish dates are limited by relations." - changing_dates_affects_follow_relations: "Changing these dates will affect dates of related work packages." - click_on_show_relations_to_open_gantt: 'Click on "%{button_name}" for GANTT overview.' - show_relations: "Show relations" - ignore_non_working_days: - title: "Working days only" description_filter: "Filter" description_enter_text: "Enter text" description_options_hide: "Hide options" diff --git a/config/routes.rb b/config/routes.rb index d04b116b2c1a..53c962fe2fd7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -654,6 +654,7 @@ get "/export_dialog" => "work_packages#export_dialog", on: :collection, as: "export_dialog" get :show_conflict_flash_message, on: :collection # we don't need a specific work package for this + get "/datepicker_dialog_content" => "work_packages#datepicker_dialog_content", on: :member, as: "datepicker_dialog_content" get "/split_view/update_counter" => "work_packages/split_view#update_counter", on: :member diff --git a/frontend/src/app/core/path-helper/path-helper.service.ts b/frontend/src/app/core/path-helper/path-helper.service.ts index af2408a19e4d..758d1f448e7e 100644 --- a/frontend/src/app/core/path-helper/path-helper.service.ts +++ b/frontend/src/app/core/path-helper/path-helper.service.ts @@ -308,6 +308,10 @@ export class PathHelperService { return `${this.workPackagePath(workPackageId)}/split_view/get_relations_counter`; } + public workPackageDatepickerDialogContentPath(workPackageId:string|number) { + return `${this.workPackagePath(workPackageId)}/datepicker_dialog_content`; + } + // Work Package Bulk paths public workPackagesBulkEditPath() { diff --git a/frontend/src/app/shared/components/datepicker/datepicker.module.ts b/frontend/src/app/shared/components/datepicker/datepicker.module.ts index 3cfc24d62ef0..25f56ca82c22 100644 --- a/frontend/src/app/shared/components/datepicker/datepicker.module.ts +++ b/frontend/src/app/shared/components/datepicker/datepicker.module.ts @@ -17,6 +17,7 @@ import { OpBasicDatePickerModule } from './basic-datepicker.module'; import { OpSpotModule } from 'core-app/spot/spot.module'; import { OpenprojectModalModule } from '../modal/modal.module'; import { OpDatePickerSheetComponent } from 'core-app/shared/components/datepicker/sheet/date-picker-sheet.component'; +import { OpenprojectContentLoaderModule } from 'core-app/shared/components/op-content-loader/openproject-content-loader.module'; @NgModule({ imports: [ @@ -27,6 +28,7 @@ import { OpDatePickerSheetComponent } from 'core-app/shared/components/datepicke OpSpotModule, OpBasicDatePickerModule, OpenprojectModalModule, + OpenprojectContentLoaderModule, ], providers: [ diff --git a/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.html b/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.html index 90d1fb365405..96e219433d60 100644 --- a/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.html +++ b/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.html @@ -7,13 +7,22 @@ tabindex="0" (submit)="doSave($event)" > - + + + -
-
+ + + + + + + + +
diff --git a/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.ts b/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.ts index d1dd3a218f95..357015d8c6d8 100644 --- a/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.ts +++ b/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.ts @@ -82,6 +82,7 @@ import { WorkPackageChangeset } from 'core-app/features/work-packages/components import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; import { DateModalSchedulingService } from '../services/date-modal-scheduling.service'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; export type DateKeys = 'start'|'end'; export type DateFields = DateKeys|'duration'; @@ -132,6 +133,8 @@ export class OpWpMultiDateFormComponent extends UntilDestroyedMixin implements A @Output() save = new EventEmitter(); + public turboFrameSrc:string; + text = { save: this.I18n.t('js.button_save'), cancel: this.I18n.t('js.button_cancel'), @@ -243,6 +246,7 @@ export class OpWpMultiDateFormComponent extends UntilDestroyedMixin implements A readonly deviceService:DeviceService, readonly weekdayService:WeekdayService, readonly focusHelper:FocusHelperService, + readonly pathHelper:PathHelperService, ) { super(); @@ -289,6 +293,8 @@ export class OpWpMultiDateFormComponent extends UntilDestroyedMixin implements A this.dates.start = this.changeset.value('startDate'); this.dates.end = this.changeset.value('dueDate'); this.setCurrentActivatedField(this.initialActivatedField); + + this.turboFrameSrc = this.pathHelper.workPackageDatepickerDialogContentPath(this.changeset.id); } ngAfterViewInit():void { From b70d3fdac0fc16a058978a1c7c289b8cec7690e5 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 9 Dec 2024 15:04:05 +0100 Subject: [PATCH 02/92] Remove unused angular components which are going to be replaced by ViewComponents --- frontend/src/app/app.module.ts | 3 +- .../banner/datepicker-banner.component.html | 39 -- .../banner/datepicker-banner.component.ts | 124 ----- .../datepicker/datepicker.module.ts | 7 - .../multi-date-picker.component.html | 107 ---- .../multi-date-picker.component.ts | 501 ------------------ ...atepicker-scheduling-toggle.component.html | 12 - .../datepicker-scheduling-toggle.component.ts | 60 --- ...epicker-working-days-toggle.component.html | 13 - ...atepicker-working-days-toggle.component.ts | 63 --- .../wp-single-date-form.component.html | 8 +- frontend/src/app/shared/shared.module.ts | 12 - .../forms/dsl/single_date_picker_input.rb | 2 +- 13 files changed, 7 insertions(+), 944 deletions(-) delete mode 100644 frontend/src/app/shared/components/datepicker/banner/datepicker-banner.component.html delete mode 100644 frontend/src/app/shared/components/datepicker/banner/datepicker-banner.component.ts delete mode 100644 frontend/src/app/shared/components/datepicker/multi-date-picker/multi-date-picker.component.html delete mode 100644 frontend/src/app/shared/components/datepicker/multi-date-picker/multi-date-picker.component.ts delete mode 100644 frontend/src/app/shared/components/datepicker/scheduling-mode/datepicker-scheduling-toggle.component.html delete mode 100644 frontend/src/app/shared/components/datepicker/scheduling-mode/datepicker-scheduling-toggle.component.ts delete mode 100644 frontend/src/app/shared/components/datepicker/toggle/datepicker-working-days-toggle.component.html delete mode 100644 frontend/src/app/shared/components/datepicker/toggle/datepicker-working-days-toggle.component.ts diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 76e5546d8339..9e48dbc96f7e 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -415,7 +415,7 @@ export class OpenProjectModule implements DoBootstrap { private registerCustomElements(injector:Injector) { registerCustomElement('opce-macro-embedded-table', EmbeddedTablesMacroComponent, { injector }); registerCustomElement('opce-principal', OpPrincipalComponent, { injector }); - registerCustomElement('opce-single-date-picker', OpBasicSingleDatePickerComponent, { injector }); + registerCustomElement('opce-basic-single-date-picker', OpBasicSingleDatePickerComponent, { injector }); registerCustomElement('opce-range-date-picker', OpBasicRangeDatePickerComponent, { injector }); registerCustomElement('opce-global-search', GlobalSearchInputComponent, { injector }); registerCustomElement('opce-autocompleter', OpAutocompleterComponent, { injector }); @@ -448,7 +448,6 @@ export class OpenProjectModule implements DoBootstrap { registerCustomElement('opce-timer-account-menu', TimerAccountMenuComponent, { injector }); registerCustomElement('opce-remote-field-updater', RemoteFieldUpdaterComponent, { injector }); registerCustomElement('opce-modal-single-date-picker', OpModalSingleDatePickerComponent, { injector }); - registerCustomElement('opce-basic-single-date-picker', OpBasicSingleDatePickerComponent, { injector }); registerCustomElement('opce-spot-drop-modal-portal', SpotDropModalPortalComponent, { injector }); registerCustomElement('opce-spot-switch', SpotSwitchComponent, { injector }); registerCustomElement('opce-modal-overlay', OpModalOverlayComponent, { injector }); diff --git a/frontend/src/app/shared/components/datepicker/banner/datepicker-banner.component.html b/frontend/src/app/shared/components/datepicker/banner/datepicker-banner.component.html deleted file mode 100644 index 25530634cc5e..000000000000 --- a/frontend/src/app/shared/components/datepicker/banner/datepicker-banner.component.html +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - diff --git a/frontend/src/app/shared/components/datepicker/banner/datepicker-banner.component.ts b/frontend/src/app/shared/components/datepicker/banner/datepicker-banner.component.ts deleted file mode 100644 index 81d299f11333..000000000000 --- a/frontend/src/app/shared/components/datepicker/banner/datepicker-banner.component.ts +++ /dev/null @@ -1,124 +0,0 @@ -//-- copyright -// OpenProject is an open source project management software. -// Copyright (C) the OpenProject GmbH -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License version 3. -// -// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -// Copyright (C) 2006-2013 Jean-Philippe Lang -// Copyright (C) 2010-2013 the ChiliProject Team -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License -// as published by the Free Software Foundation; either version 2 -// of the License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -// -// See COPYRIGHT and LICENSE files for more details. -//++ - -import { - ChangeDetectionStrategy, - Component, - Injector, - Input, -} from '@angular/core'; -import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { - map, - take, -} from 'rxjs/operators'; -import { StateService } from '@uirouter/core'; -import { DateModalRelationsService } from 'core-app/shared/components/datepicker/services/date-modal-relations.service'; - -@Component({ - selector: 'op-datepicker-banner', - templateUrl: './datepicker-banner.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class OpDatePickerBannerComponent { - @Input() scheduleManually = false; - - hasRelations$ = this.dateModalRelations.hasRelations$; - - hasPrecedingRelations$ = this - .dateModalRelations - .precedingWorkPackages$ - .pipe( - map((relations) => relations?.length > 0), - ); - - hasFollowingRelations$ = this - .dateModalRelations - .followingWorkPackages$ - .pipe( - map((relations) => relations?.length > 0), - ); - - get isParent() { - return this.dateModalRelations.isParent; - } - - get isChild() { - return this.dateModalRelations.isChild; - } - - text = { - automatically_scheduled_parent: this.I18n.t('js.work_packages.datepicker_modal.automatically_scheduled_parent'), - manually_scheduled: this.I18n.t('js.work_packages.datepicker_modal.manually_scheduled'), - start_date_limited_by_relations: this.I18n.t('js.work_packages.datepicker_modal.start_date_limited_by_relations'), - changing_dates_affects_follow_relations: this.I18n.t('js.work_packages.datepicker_modal.changing_dates_affects_follow_relations'), - click_on_show_relations_to_open_gantt: this.I18n.t( - 'js.work_packages.datepicker_modal.click_on_show_relations_to_open_gantt', - { button_name: this.I18n.t('js.work_packages.datepicker_modal.show_relations') }, - ), - show_relations_button: this.I18n.t('js.work_packages.datepicker_modal.show_relations'), - }; - - constructor( - readonly dateModalRelations:DateModalRelationsService, - readonly injector:Injector, - readonly I18n:I18nService, - readonly state:StateService, - ) {} - - openGantt(evt:MouseEvent):void { - evt.preventDefault(); - - this - .dateModalRelations - .getInvolvedWorkPackageIds() - .pipe( - take(1), - ) - .subscribe((ids) => { - const props = { - c: ['id', 'subject', 'type', 'status', 'assignee', 'project', 'startDate', 'dueDate'], - t: 'id:asc', - tv: true, - hi: true, - f: [{ n: 'id', o: '=', v: ids }], - }; - - const href = this.state.href( - 'gantt.partitioned.list', - { - query_id: null, - projects: null, - projectPath: null, - query_props: JSON.stringify(props), - }, - ); - window.open(href, '_blank'); - }); - } -} diff --git a/frontend/src/app/shared/components/datepicker/datepicker.module.ts b/frontend/src/app/shared/components/datepicker/datepicker.module.ts index 25f56ca82c22..dfe83bc5ce2b 100644 --- a/frontend/src/app/shared/components/datepicker/datepicker.module.ts +++ b/frontend/src/app/shared/components/datepicker/datepicker.module.ts @@ -10,9 +10,6 @@ import { I18nService } from 'core-app/core/i18n/i18n.service'; import { OpModalSingleDatePickerComponent } from './modal-single-date-picker/modal-single-date-picker.component'; import { OpWpMultiDateFormComponent } from './wp-multi-date-form/wp-multi-date-form.component'; import { OpWpSingleDateFormComponent } from './wp-single-date-form/wp-single-date-form.component'; -import { OpDatePickerBannerComponent } from './banner/datepicker-banner.component'; -import { OpDatePickerSchedulingToggleComponent } from './scheduling-mode/datepicker-scheduling-toggle.component'; -import { OpDatePickerWorkingDaysToggleComponent } from './toggle/datepicker-working-days-toggle.component'; import { OpBasicDatePickerModule } from './basic-datepicker.module'; import { OpSpotModule } from 'core-app/spot/spot.module'; import { OpenprojectModalModule } from '../modal/modal.module'; @@ -36,10 +33,6 @@ import { OpenprojectContentLoaderModule } from 'core-app/shared/components/op-co ], declarations: [ - OpDatePickerBannerComponent, - OpDatePickerSchedulingToggleComponent, - OpDatePickerWorkingDaysToggleComponent, - OpModalSingleDatePickerComponent, OpWpMultiDateFormComponent, OpWpSingleDateFormComponent, diff --git a/frontend/src/app/shared/components/datepicker/multi-date-picker/multi-date-picker.component.html b/frontend/src/app/shared/components/datepicker/multi-date-picker/multi-date-picker.component.html deleted file mode 100644 index 94987fef9375..000000000000 --- a/frontend/src/app/shared/components/datepicker/multi-date-picker/multi-date-picker.component.html +++ /dev/null @@ -1,107 +0,0 @@ - - - -
-
-
- - - - - - - - - -
- - -
- -
-
- - -
-
-
-
- - diff --git a/frontend/src/app/shared/components/datepicker/multi-date-picker/multi-date-picker.component.ts b/frontend/src/app/shared/components/datepicker/multi-date-picker/multi-date-picker.component.ts deleted file mode 100644 index 989274d3cbbd..000000000000 --- a/frontend/src/app/shared/components/datepicker/multi-date-picker/multi-date-picker.component.ts +++ /dev/null @@ -1,501 +0,0 @@ -//-- copyright -// OpenProject is an open source project management software. -// Copyright (C) the OpenProject GmbH -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License version 3. -// -// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -// Copyright (C) 2006-2013 Jean-Philippe Lang -// Copyright (C) 2010-2013 the ChiliProject Team -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License -// as published by the Free Software Foundation; either version 2 -// of the License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -// -// See COPYRIGHT and LICENSE files for more details. -//++ - -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - ElementRef, - EventEmitter, - forwardRef, - Injector, - Input, - OnInit, - Output, - ViewChild, - ViewEncapsulation, -} from '@angular/core'; -import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { - ControlValueAccessor, - NG_VALUE_ACCESSOR, -} from '@angular/forms'; -import { - areDatesEqual, - mappedDate, - onDayCreate, - parseDate, - setDates, - validDate, -} from 'core-app/shared/components/datepicker/helpers/date-modal.helpers'; -import { TimezoneService } from 'core-app/core/datetime/timezone.service'; -import { DatePicker } from '../datepicker'; -import flatpickr from 'flatpickr'; -import { DayElement } from 'flatpickr/dist/types/instance'; -import { - ActiveDateChange, - DateFields, - DateKeys, -} from '../wp-multi-date-form/wp-multi-date-form.component'; -import { - fromEvent, - merge, - Observable, - Subject, -} from 'rxjs'; -import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; -import { - debounceTime, - filter, - map, -} from 'rxjs/operators'; -import { DeviceService } from 'core-app/core/browser/device.service'; -import { DateOption } from 'flatpickr/dist/types/options'; -import { WeekdayService } from 'core-app/core/days/weekday.service'; -import { FocusHelperService } from 'core-app/shared/directives/focus/focus-helper'; -import { SpotDropModalTeleportationService } from 'core-app/spot/components/drop-modal/drop-modal-teleportation.service'; - -@Component({ - selector: 'op-multi-date-picker', - templateUrl: './multi-date-picker.component.html', - styleUrls: ['../styles/datepicker.modal.sass'], - changeDetection: ChangeDetectionStrategy.OnPush, - encapsulation: ViewEncapsulation.None, - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => OpMultiDatePickerComponent), - multi: true, - }, - ], -}) -export class OpMultiDatePickerComponent extends UntilDestroyedMixin implements OnInit, ControlValueAccessor { - @ViewChild('modalContainer') modalContainer:ElementRef; - - @ViewChild('flatpickrTarget') flatpickrTarget:ElementRef; - - @Input() id = `flatpickr-input-${+(new Date())}`; - - @Input() name = ''; - - @Input() fieldName = ''; - - @Input() value:string[] = []; - - @Input() applyLabel:string; - - private _opened = false; - - @Input() set opened(opened:boolean) { - if (this._opened === !!opened) { - return; - } - - this._opened = !!opened; - - if (this._opened) { - this.initializeDatepickerAfterOpen(); - } else { - this.datePickerInstance?.destroy(); - this.closed.emit(); - } - } - - get opened() { - return this._opened; - } - - @Output() valueChange = new EventEmitter(); - - @Output('closed') closed = new EventEmitter(); - - text = { - apply: this.I18n.t('js.modals.button_apply'), - cancel: this.I18n.t('js.button_cancel'), - startDate: this.I18n.t('js.work_packages.properties.startDate'), - endDate: this.I18n.t('js.work_packages.properties.dueDate'), - placeholder: this.I18n.t('js.placeholders.default'), - today: this.I18n.t('js.label_today'), - days: (count:number):string => this.I18n.t('js.units.day', { count }), - ignoreNonWorkingDays: { - title: this.I18n.t('js.work_packages.datepicker_modal.ignore_non_working_days.title'), - }, - }; - - get datesString():string { - if (this.value?.[0] && this.value?.[1]) { - return `${this.value[0]} - ${this.value[1]}`; - } - - return this.text.placeholder; - } - - ignoreNonWorkingDays = true; - - currentlyActivatedDateField:DateFields; - - htmlId = ''; - - dates:{ [key in DateKeys]:string|null } = { - start: null, - end: null, - }; - - // Manual changes from the inputs to start and end dates - startDateChanged$ = new Subject(); - - startDateDebounced$:Observable = this.debouncedInput(this.startDateChanged$, 'start'); - - endDateChanged$ = new Subject(); - - endDateDebounced$:Observable = this.debouncedInput(this.endDateChanged$, 'end'); - - // Manual changes to the datepicker, with information which field was active - datepickerChanged$ = new Subject(); - - ignoreNonWorkingDaysWritable = true; - - private datePickerInstance:DatePicker; - - constructor( - readonly injector:Injector, - readonly cdRef:ChangeDetectorRef, - readonly I18n:I18nService, - readonly timezoneService:TimezoneService, - readonly deviceService:DeviceService, - readonly weekdayService:WeekdayService, - readonly focusHelper:FocusHelperService, - readonly spotDropModalTeleportationService:SpotDropModalTeleportationService, - ) { - super(); - - merge( - this.startDateDebounced$, - this.endDateDebounced$, - this.datepickerChanged$, - ) - .pipe( - this.untilDestroyed(), - filter(() => !!this.datePickerInstance), - ) - .subscribe(([field, update]) => { - // When clearing the one date, clear the others as well - if (update !== null) { - this.handleSingleDateUpdate(field, update); - } - - this.cdRef.detectChanges(); - }); - } - - ngOnInit():void { - this.applyLabel = this.applyLabel || this.text.apply; - this.htmlId = `wp-datepicker-${this.fieldName}`; - this.dates.start = this.value?.[0]; - this.dates.end = this.value?.[1]; - - this.setCurrentActivatedField(this.initialActivatedField); - } - - onInputClick(event:MouseEvent) { - event.stopPropagation(); - } - - close():void { - this.opened = false; - } - - changeNonWorkingDays():void { - this.initializeDatepicker(); - this.cdRef.detectChanges(); - } - - save($event:Event):void { - $event.preventDefault(); - const value = [ - this.dates.start || '', - this.dates.end || '', - ]; - this.value = value; - this.valueChange.emit(value); - this.onChange(value); - this.close(); - } - - updateDate(key:DateKeys, val:string|null):void { - if ((val === null || validDate(val)) && this.datePickerInstance) { - this.dates[key] = mappedDate(val); - const dateValue = parseDate(val || '') || undefined; - this.enforceManualChangesToDatepicker(dateValue); - this.cdRef.detectChanges(); - } - } - - setCurrentActivatedField(val:DateFields):void { - this.currentlyActivatedDateField = val; - } - - toggleCurrentActivatedField():void { - this.currentlyActivatedDateField = this.currentlyActivatedDateField === 'start' ? 'end' : 'start'; - } - - isStateOfCurrentActivatedField(val:DateFields):boolean { - return this.currentlyActivatedDateField === val; - } - - setToday(key:DateKeys):void { - this.datepickerChanged$.next([key, new Date()]); - - const nextActive = key === 'start' ? 'end' : 'start'; - this.setCurrentActivatedField(nextActive); - } - - showFieldAsActive(field:DateFields):boolean { - return this.isStateOfCurrentActivatedField(field); - } - - private initializeDatepickerAfterOpen():void { - this.spotDropModalTeleportationService - .afterRenderOnce$(true) - .subscribe(() => { - this.initializeDatepicker(); - }); - } - - private initializeDatepicker(minimalDate?:Date|null) { - this.datePickerInstance?.destroy(); - this.datePickerInstance = new DatePicker( - this.injector, - this.id, - [this.dates.start || '', this.dates.end || ''], - { - mode: 'range', - showMonths: this.deviceService.isMobile ? 1 : 2, - inline: true, - onReady: (_date, _datestr, instance) => { - instance.calendarContainer.classList.add('op-datepicker-modal--flatpickr-instance'); - - this.ensureHoveredSelection(instance.calendarContainer); - }, - onChange: (dates:Date[], _datestr, instance) => { - this.onTouched(); - - if (dates.length === 2) { - this.setDates(dates[0], dates[1]); - this.toggleCurrentActivatedField(); - this.cdRef.detectChanges(); - return; - } - - // Update with the same flow as entering a value - const { latestSelectedDateObj } = instance as { latestSelectedDateObj:Date }; - const activeField = this.currentlyActivatedDateField; - this.handleSingleDateUpdate(activeField, latestSelectedDateObj); - this.cdRef.detectChanges(); - }, - onDayCreate: async (dObj:Date[], dStr:string, fp:flatpickr.Instance, dayElem:DayElement) => { - onDayCreate( - dayElem, - this.ignoreNonWorkingDays, - await this.datePickerInstance?.isNonWorkingDay(dayElem.dateObj), - this.isDayDisabled(dayElem, minimalDate), - ); - }, - }, - this.flatpickrTarget.nativeElement as HTMLElement, - ); - } - - private enforceManualChangesToDatepicker(enforceDate?:Date) { - let startDate = parseDate(this.dates.start || ''); - let endDate = parseDate(this.dates.end || ''); - - if (startDate && endDate) { - // If the start date is manually changed to be after the end date, - // we adjust the end date to be at least the same as the newly entered start date. - // Same applies if the end date is set manually before the current start date - if (startDate > endDate && this.isStateOfCurrentActivatedField('start')) { - endDate = startDate; - this.dates.end = this.timezoneService.formattedISODate(endDate); - } else if (endDate < startDate && this.isStateOfCurrentActivatedField('end')) { - startDate = endDate; - this.dates.start = this.timezoneService.formattedISODate(startDate); - } - } - - const dates = [startDate, endDate]; - setDates(dates, this.datePickerInstance, enforceDate); - } - - private setDates(newStart:Date, newEnd:Date) { - this.dates.start = this.timezoneService.formattedISODate(newStart); - this.dates.end = this.timezoneService.formattedISODate(newEnd); - } - - private handleSingleDateUpdate(activeField:DateFields, selectedDate:Date) { - if (activeField === 'duration') { - return; - } - - this.replaceDatesWithNewSelection(activeField, selectedDate); - - // Set the selected date on the datepicker - this.enforceManualChangesToDatepicker(selectedDate); - } - - private replaceDatesWithNewSelection(activeField:DateFields, selectedDate:Date) { - /** - Overwrite flatpickr default behavior by not starting a new date range everytime but preserving either start or end date. - There are three cases to cover. - 1. Everything before the current start date will become the new start date (independent of the active field) - 2. Everything after the current end date will become the new end date if that is the currently active field. - If the active field is the start date, the selected date becomes the new start date and the end date is cleared. - 3. Everything in between the current start and end date is dependent on the currently activated field. - * */ - - const parsedStartDate = parseDate(this.dates.start || '') as Date; - const parsedEndDate = parseDate(this.dates.end || '') as Date; - - if (selectedDate < parsedStartDate) { - if (activeField === 'start') { - // Set start, derive end from - this.applyNewDates([selectedDate]); - } else { - // Reset and end date - this.applyNewDates(['', selectedDate]); - } - } else if (selectedDate > parsedEndDate) { - if (activeField === 'end') { - this.applyNewDates([parsedStartDate, selectedDate]); - } else { - // Reset and end date - this.applyNewDates([selectedDate]); - } - } else if (areDatesEqual(selectedDate, parsedStartDate) || areDatesEqual(selectedDate, parsedEndDate)) { - this.applyNewDates([selectedDate, selectedDate]); - } else { - const newDates = activeField === 'start' ? [selectedDate, parsedEndDate] : [parsedStartDate, selectedDate]; - this.applyNewDates(newDates); - } - } - - private applyNewDates([start, end]:DateOption[]) { - this.dates.start = start ? this.timezoneService.formattedISODate(start) : null; - this.dates.end = end ? this.timezoneService.formattedISODate(end) : null; - - // Apply the dates to the datepicker - setDates([start, end], this.datePickerInstance); - } - - private get initialActivatedField():DateFields { - switch (this.fieldName) { - case 'startDate': - return 'start'; - case 'dueDate': - return 'end'; - case 'duration': - return 'duration'; - default: - return (this.dates.start && !this.dates.end) ? 'end' : 'start'; - } - } - - private isDayDisabled(dayElement:DayElement, minimalDate?:Date|null):boolean { - return !!minimalDate && dayElement.dateObj <= minimalDate; - } - - private debouncedInput(input$:Subject, key:DateKeys):Observable { - return input$ - .pipe( - this.untilDestroyed(), - // Skip values that are already set as the current model - filter((value) => value !== this.dates[key]), - // Avoid that the manual changes are moved to the datepicker too early. - // The debounce is chosen quite large on purpose to catch the following case: - // 1. Start date is for example 2022-07-15. The user wants to set the end date to the 19th. - // 2. So he/she starts entering the finish date 2022-07-1 . - // 3. This is already a valid date. Since it is before the start date,the start date would be changed automatically to the first without the debounce. - // 4. The debounce gives the user enough time to type the last number "9" before the changes are converted to the datepicker and the start date would be affected. - debounceTime(500), - filter((date) => validDate(date)), - map((date) => { - if (date === '') { - return null; - } - - return parseDate(date) as Date; - }), - map((date) => [key, date]), - ); - } - - /** - * When hovering selections in the range datepicker, the range usually - * stays active no matter where the cursor is. - * - * We want to hide any hovered selection preview when we leave the datepicker. - * @param calendarContainer - * @private - */ - private ensureHoveredSelection(calendarContainer:HTMLDivElement) { - fromEvent(calendarContainer, 'mouseenter') - .pipe( - this.untilDestroyed(), - ) - .subscribe(() => calendarContainer.classList.remove('flatpickr-container-suppress-hover')); - - fromEvent(calendarContainer, 'mouseleave') - .pipe( - this.untilDestroyed(), - filter(() => !(!!this.dates.start && !!this.dates.end)), - ) - .subscribe(() => calendarContainer.classList.add('flatpickr-container-suppress-hover')); - } - - writeValue(newValue:string[]|null):void { - const value = (newValue || []).map((d) => this.timezoneService.formattedISODate(d)); - if (value[0] === this.dates.start && value[1] === this.dates.end) { - return; - } - this.value = value; - this.dates.start = this.value[0]; - this.dates.end = this.value[1]; - } - - onChange = (_:string[]):void => {}; - - onTouched = ():void => {}; - - registerOnChange(fn:(_:string[]) => void):void { - this.onChange = fn; - } - - registerOnTouched(fn:() => void):void { - this.onTouched = fn; - } -} diff --git a/frontend/src/app/shared/components/datepicker/scheduling-mode/datepicker-scheduling-toggle.component.html b/frontend/src/app/shared/components/datepicker/scheduling-mode/datepicker-scheduling-toggle.component.html deleted file mode 100644 index b3c3b6eb1d91..000000000000 --- a/frontend/src/app/shared/components/datepicker/scheduling-mode/datepicker-scheduling-toggle.component.html +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/frontend/src/app/shared/components/datepicker/scheduling-mode/datepicker-scheduling-toggle.component.ts b/frontend/src/app/shared/components/datepicker/scheduling-mode/datepicker-scheduling-toggle.component.ts deleted file mode 100644 index d0aa89cdf955..000000000000 --- a/frontend/src/app/shared/components/datepicker/scheduling-mode/datepicker-scheduling-toggle.component.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - forwardRef, - Input, -} from '@angular/core'; -import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { - ControlValueAccessor, - NG_VALUE_ACCESSOR, -} from '@angular/forms'; - -@Component({ - selector: 'op-datepicker-scheduling-toggle', - templateUrl: './datepicker-scheduling-toggle.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => OpDatePickerSchedulingToggleComponent), - multi: true, - }], -}) -export class OpDatePickerSchedulingToggleComponent implements ControlValueAccessor { - text = { - scheduling: { - title: this.I18n.t('js.scheduling.manual'), - }, - }; - - @Input() scheduleManually:boolean; - - constructor( - private I18n:I18nService, - private cdRef:ChangeDetectorRef, - ) { } - - onChange = (_:boolean):void => {}; - - onTouched = (_:boolean):void => {}; - - registerOnChange(fn:(_:boolean) => void):void { - this.onChange = fn; - } - - registerOnTouched(fn:(_:boolean) => void):void { - this.onTouched = fn; - } - - writeValue(val:boolean):void { - this.scheduleManually = val; - this.cdRef.markForCheck(); - } - - onToggle(value:boolean):void { - this.writeValue(value); - this.onChange(value); - this.onTouched(value); - } -} diff --git a/frontend/src/app/shared/components/datepicker/toggle/datepicker-working-days-toggle.component.html b/frontend/src/app/shared/components/datepicker/toggle/datepicker-working-days-toggle.component.html deleted file mode 100644 index d17768ba5f19..000000000000 --- a/frontend/src/app/shared/components/datepicker/toggle/datepicker-working-days-toggle.component.html +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/frontend/src/app/shared/components/datepicker/toggle/datepicker-working-days-toggle.component.ts b/frontend/src/app/shared/components/datepicker/toggle/datepicker-working-days-toggle.component.ts deleted file mode 100644 index 36f50d08ddde..000000000000 --- a/frontend/src/app/shared/components/datepicker/toggle/datepicker-working-days-toggle.component.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - forwardRef, - Input, -} from '@angular/core'; -import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { - ControlValueAccessor, - NG_VALUE_ACCESSOR, -} from '@angular/forms'; - -@Component({ - selector: 'op-datepicker-working-days-toggle', - templateUrl: './datepicker-working-days-toggle.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => OpDatePickerWorkingDaysToggleComponent), - multi: true, - }], -}) -export class OpDatePickerWorkingDaysToggleComponent implements ControlValueAccessor { - @Input() ignoreNonWorkingDays:boolean; - - @Input() disabled = false; - - text = { - ignoreNonWorkingDays: { - title: this.I18n.t('js.work_packages.datepicker_modal.ignore_non_working_days.title'), - }, - }; - - constructor( - private I18n:I18nService, - private cdRef:ChangeDetectorRef, - ) {} - - onChange = (_:boolean):void => {}; - - onTouched = (_:boolean):void => {}; - - registerOnChange(fn:(_:boolean) => void):void { - this.onChange = fn; - } - - registerOnTouched(fn:(_:boolean) => void):void { - this.onTouched = fn; - } - - onToggle(value:boolean):void { - const ignoreNonWorkingDays = !value; - this.writeValue(ignoreNonWorkingDays); - this.onChange(ignoreNonWorkingDays); - this.onTouched(ignoreNonWorkingDays); - } - - writeValue(val:boolean):void { - this.ignoreNonWorkingDays = val; - this.cdRef.markForCheck(); - } -} diff --git a/frontend/src/app/shared/components/datepicker/wp-single-date-form/wp-single-date-form.component.html b/frontend/src/app/shared/components/datepicker/wp-single-date-form/wp-single-date-form.component.html index dc89ece75d05..78876389694c 100644 --- a/frontend/src/app/shared/components/datepicker/wp-single-date-form/wp-single-date-form.component.html +++ b/frontend/src/app/shared/components/datepicker/wp-single-date-form/wp-single-date-form.component.html @@ -7,18 +7,20 @@ (submit)="doSave($event)" tabindex="0" > - + class="op-datepicker-modal--banner" + >-->
+ Date: Mon, 9 Dec 2024 15:11:13 +0100 Subject: [PATCH 03/92] Use existing fields for now to get the modal working again --- .../dialog_content_component.html.erb | 20 -- .../datepicker/styles/datepicker.modal.sass | 32 +--- .../wp-multi-date-form.component.html | 176 +++++++++--------- 3 files changed, 88 insertions(+), 140 deletions(-) diff --git a/app/components/work_packages/date_picker/dialog_content_component.html.erb b/app/components/work_packages/date_picker/dialog_content_component.html.erb index a60a86b44706..dea60f4c1f4d 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.html.erb +++ b/app/components/work_packages/date_picker/dialog_content_component.html.erb @@ -37,26 +37,6 @@ end end end - - body.with_row(mb: 3) do - flex_layout(justify_content: :space_between, gap: 3) do |second_row| - second_row.with_column do - render(Primer::Alpha::TextField.new(name: :start_date, - label: I18n.t("attributes.start_date"), - value: work_package.start_date)) - end - second_row.with_column do - render(Primer::Alpha::TextField.new(name: :due_date, - label: I18n.t("attributes.due_date"), - value: work_package.due_date)) - end - second_row.with_column do - render(Primer::Alpha::TextField.new(name: :duration, - label: I18n.t("activerecord.attributes.work_package.duration"), - value: work_package.duration)) - end - end - end end end end diff --git a/frontend/src/app/shared/components/datepicker/styles/datepicker.modal.sass b/frontend/src/app/shared/components/datepicker/styles/datepicker.modal.sass index 904eb15044cd..4c8ad4a35360 100644 --- a/frontend/src/app/shared/components/datepicker/styles/datepicker.modal.sass +++ b/frontend/src/app/shared/components/datepicker/styles/datepicker.modal.sass @@ -16,25 +16,11 @@ &_wide max-width: 600px - &--banner - margin-bottom: $spot-spacing-1 - - &:empty - visibility: hidden - - &--toggle-actions-container - display: grid - grid-template-columns: auto auto 1fr - grid-column-gap: $spot-spacing-2 - margin: 0 $spot-spacing-1 0 - .spot-form-field--input:has(spot-switch) - margin-bottom: 0px !important - &--dates-container display: grid grid-template-columns: 1fr 1fr 150px grid-gap: $spot-spacing-1 - margin: $spot-spacing-1 $spot-spacing-1 0 + margin: 0 $spot-spacing-1 &--date-field &_current, @@ -56,17 +42,6 @@ margin: 0.5rem auto 0 auto !important overflow: hidden - &--stretch-content - flex-grow: 1 - flex-shrink: 1 - overflow-y: auto - - // Unfortunately, we need an extra class - // Of specificity here to overwrite the - // nested spot-container styles - &.spot-container - margin-top: 0 - @media screen and (max-width: $breakpoint-sm) .op-datepicker-modal // Use same width as spot-modal mobile @@ -79,11 +54,6 @@ &--dates-container grid-template-columns: 1fr 1fr - &--toggle-actions-container - display: grid - grid-template-columns: 1fr - grid-row-gap: $spot-spacing-1 - &--date-form &:only-child grid-column: 1 / 3 diff --git a/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.html b/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.html index 96e219433d60..e0e44644f185 100644 --- a/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.html +++ b/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.html @@ -11,18 +11,17 @@ [src]="turboFrameSrc" id="wp-datepicker-dialog--content"> - + - + - + - + - -
- - - - - - - - - - - -
+
+ + + + + + + + + + + +
- -
--> +
From 14f9226a6953a3f56a84c001208318e50e6e6c56 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Tue, 10 Dec 2024 15:03:32 +0100 Subject: [PATCH 04/92] Render the form from rails and only the actual datepicker in angular. --- .../dialog_content_component.html.erb | 102 +++++++--- .../date_picker/dialog_content_component.rb | 8 +- .../work_packages/date_picker_controller.rb | 140 +++++++++++++ app/controllers/work_packages_controller.rb | 26 +-- .../show.html.erb} | 0 config/routes.rb | 3 +- frontend/src/app/app.module.ts | 2 + .../core/path-helper/path-helper.service.ts | 2 +- .../datepicker/datepicker.module.ts | 6 + .../wp-modal-date-picker.component.ts | 184 ++++++++++++++++++ .../wp-multi-date-dialog-content.component.ts | 82 ++++++++ .../combined-date-edit-field.component.html | 9 +- 12 files changed, 510 insertions(+), 54 deletions(-) create mode 100644 app/controllers/work_packages/date_picker_controller.rb rename app/views/work_packages/{datepicker_dialog_content.html.erb => date_picker/show.html.erb} (100%) create mode 100644 frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts create mode 100644 frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-dialog-content.component.ts diff --git a/app/components/work_packages/date_picker/dialog_content_component.html.erb b/app/components/work_packages/date_picker/dialog_content_component.html.erb index dea60f4c1f4d..4e47c7e08ca2 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.html.erb +++ b/app/components/work_packages/date_picker/dialog_content_component.html.erb @@ -6,36 +6,92 @@ end collection.with_component(Primer::Alpha::Dialog::Body.new) do - flex_layout do |body| - body.with_row(mb: 3) do - flex_layout(align_items: :flex_end, justify_content: :space_between) do |first_row| - first_row.with_column do - render(Primer::Alpha::FormControl.new(label: I18n.t("work_packages.datepicker_modal.mode.title"))) do |component| - component.with_input do - render(Primer::Alpha::SegmentedControl.new("aria-label": I18n.t("work_packages.datepicker_modal.mode.title"))) do |control| - control.with_item(tag: :a, - href: datepicker_dialog_content_work_package_path(manually_scheduled: true), - data: { turbo_stream: true }, - label: I18n.t("work_packages.datepicker_modal.mode.manual"), - title: I18n.t("work_packages.datepicker_modal.mode.manual"), - selected: manually_scheduled) - control.with_item(tag: :a, - href: datepicker_dialog_content_work_package_path(manually_scheduled: false), - data: { turbo_stream: true }, - label: I18n.t("work_packages.datepicker_modal.mode.automatic"), - title: I18n.t("work_packages.datepicker_modal.mode.automatic"), - selected: !manually_scheduled) + primer_form_with( + model: work_package, + url: work_package_datepicker_dialog_content_path, + method: :put, + id: DIALOG_FORM_ID, + html: { autocomplete: "off" }, + ) do |f| + flex_layout do |body| + body.with_row(mb: 3) do + flex_layout(align_items: :flex_end, justify_content: :space_between) do |first_row| + first_row.with_column do + render(Primer::Alpha::FormControl.new(label: I18n.t("work_packages.datepicker_modal.mode.title"))) do |component| + component.with_input do + render(Primer::Alpha::SegmentedControl.new("aria-label": I18n.t("work_packages.datepicker_modal.mode.title"))) do |control| + control.with_item(tag: :a, + href: work_package_datepicker_dialog_content_path(manually_scheduled: true), + data: { turbo_stream: true }, + label: I18n.t("work_packages.datepicker_modal.mode.manual"), + title: I18n.t("work_packages.datepicker_modal.mode.manual"), + selected: manually_scheduled) + control.with_item(tag: :a, + href: work_package_datepicker_dialog_content_path(manually_scheduled: false), + data: { turbo_stream: true }, + label: I18n.t("work_packages.datepicker_modal.mode.automatic"), + title: I18n.t("work_packages.datepicker_modal.mode.automatic"), + selected: !manually_scheduled) + end end end end + + first_row.with_column(mb: 1) do + render(Primer::Alpha::CheckBox.new(name: "ignore_non_working_days", + label: I18n.t("work_packages.datepicker_modal.ignore_non_working_days.title"), + checked: work_package.ignore_non_working_days)) + end end + end - first_row.with_column(mb: 1) do - render(Primer::Alpha::CheckBox.new(name: "name", - label: I18n.t("work_packages.datepicker_modal.ignore_non_working_days.title"), - checked: work_package.ignore_non_working_days)) + body.with_row(mb: 3) do + flex_layout(justify_content: :space_between) do |second_row| + second_row.with_column(mr: 3) do + render(Primer::Alpha::TextField.new(name: :start_date, + label: I18n.t("attributes.start_date"), + data: { + focus: focused_field == :start_date, + }, + value: work_package.start_date)) + end + second_row.with_column(mr: 3) do + render(Primer::Alpha::TextField.new(name: :due_date, + label: I18n.t("attributes.due_date"), + data: { + focus: focused_field == :start_date, + }, + value: work_package.due_date)) + end + second_row.with_column do + render(Primer::Alpha::TextField.new(name: :duration, + label: I18n.t("activerecord.attributes.work_package.duration"), + value: work_package.duration)) + end end end + + body.with_row(mb: 3) do + helpers.angular_component_tag "opce-wp-modal-date-picker", + inputs: { + start_date: work_package.start_date, + due_date: work_package.due_date, + ignore_non_working_days: work_package.ignore_non_working_days, + schedule_manually: manually_scheduled + } + end + end + end + end + + collection.with_component(Primer::Alpha::Dialog::Footer.new) do + component_collection do |footer| + footer.with_component(Primer::ButtonComponent.new) do + I18n.t("button_cancel") + end + + footer.with_component(Primer::ButtonComponent.new(scheme: :primary, type: :submit, form: DIALOG_FORM_ID)) do + I18n.t("button_submit") end end end diff --git a/app/components/work_packages/date_picker/dialog_content_component.rb b/app/components/work_packages/date_picker/dialog_content_component.rb index 338b89310e2d..5406e92e15e3 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.rb +++ b/app/components/work_packages/date_picker/dialog_content_component.rb @@ -33,13 +33,17 @@ class DialogContentComponent < ApplicationComponent include OpPrimer::ComponentHelpers include OpTurbo::Streamable - attr_accessor :work_package, :manually_scheduled + DIALOG_FORM_ID = "datepicker-form" - def initialize(work_package:, manually_scheduled: true) + attr_accessor :work_package, :manually_scheduled, :focused_field, :touched_field_map + + def initialize(work_package:, manually_scheduled: true, focused_field: :start_date, touched_field_map: {}) super @work_package = work_package @manually_scheduled = ActiveModel::Type::Boolean.new.cast(manually_scheduled) + @focused_field = focused_field + @touched_field_map = touched_field_map end private diff --git a/app/controllers/work_packages/date_picker_controller.rb b/app/controllers/work_packages/date_picker_controller.rb new file mode 100644 index 000000000000..d25e4078245c --- /dev/null +++ b/app/controllers/work_packages/date_picker_controller.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +# -- 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. +# ++ + +class WorkPackages::DatePickerController < ApplicationController + include OpTurbo::ComponentStream + + ERROR_PRONE_ATTRIBUTES = %i[start_date + due_date + duration].freeze + + layout false + + before_action :find_work_package + authorization_checked! :show, :update + + attr_accessor :work_package + + def show + respond_to do |format| + format.html do + render :show, + locals: { work_package:, manually_scheduled:, params: }, + layout: false + end + + format.turbo_stream do + replace_via_turbo_stream( + component: WorkPackages::DatePicker::DialogContentComponent.new(work_package:, manually_scheduled:) + ) + render turbo_stream: turbo_streams + end + end + end + + def update + service_call = WorkPackages::UpdateService + .new(user: current_user, + model: @work_package) + .call(work_package_datepicker_params) + + if service_call.success? + respond_to do |format| + format.turbo_stream do + render turbo_stream: [] + end + end + else + respond_to do |format| + format.turbo_stream do + # Bundle 422 status code into stream response so + # Angular has context as to the success or failure of + # the request in order to fetch the new set of Work Package + # attributes in the ancestry solely on success. + render turbo_stream: [ + turbo_stream.morph("work_package_datepicker_modal", datepicker_modal_component) + ], status: :unprocessable_entity + end + end + end + end + + private + + def progress_modal_component + WorkPackages::DatePicker::DialogContentComponent.new(work_package: @work_package, manually_scheduled:, focused_field:, + touched_field_map:) + end + + def focused_field + params[:field] + end + + def find_work_package + @work_package = WorkPackage.visible.find(params[:work_package_id]) + end + + def touched_field_map + params.require(:work_package) + .slice("schedule_manually_touched", + "ignore_non_working_days_touched", + "start_date_touched", + "due_date_touched", + "duration_touched") + .transform_values { _1 == "true" } + .permit! + end + + def manually_scheduled + if params[:manually_scheduled].present? + params.delete(:manually_scheduled) + else + work_package.schedule_manually + end + end + + def work_package_datepicker_params + params.require(:work_package) + .slice(*allowed_touched_params) + .permit! + end + + def allowed_touched_params + allowed_params.filter { touched?(_1) } + end + + def allowed_params + %i[schedule_manually ignore_non_working_days start_date due_date duration] + end + + def touched?(field) + touched_field_map[:"#{field}_touched"] + end +end diff --git a/app/controllers/work_packages_controller.rb b/app/controllers/work_packages_controller.rb index 5e18a58ca21b..6e23024c6d74 100644 --- a/app/controllers/work_packages_controller.rb +++ b/app/controllers/work_packages_controller.rb @@ -38,13 +38,12 @@ class WorkPackagesController < ApplicationController before_action :authorize_on_work_package, :project, only: %i[show generate_pdf_dialog generate_pdf] - before_action :authorize_on_work_package, only: %i[datepicker_dialog_content] before_action :load_and_authorize_in_optional_project, :check_allowed_export, :protect_from_unauthorized_export, only: %i[index export_dialog] before_action :authorize, only: :show_conflict_flash_message - authorization_checked! :index, :show, :export_dialog, :generate_pdf_dialog, :generate_pdf, :datepicker_dialog_content + authorization_checked! :index, :show, :export_dialog, :generate_pdf_dialog, :generate_pdf before_action :load_and_validate_query, only: :index, unless: -> { request.format.html? } before_action :load_work_packages, only: :index, if: -> { request.format.atom? } @@ -107,29 +106,6 @@ def generate_pdf redirect_back(fallback_location: work_package_path(work_package)) end - def datepicker_dialog_content - manually_scheduled = if params[:manually_scheduled].present? - params.delete(:manually_scheduled) - else - work_package.schedule_manually - end - - respond_to do |format| - format.html do - render :datepicker_dialog_content, - locals: { work_package:, manually_scheduled:, params: }, - layout: false - end - - format.turbo_stream do - replace_via_turbo_stream( - component: WorkPackages::DatePicker::DialogContentComponent.new(work_package:, manually_scheduled:) - ) - render turbo_stream: turbo_streams - end - end - end - def show_conflict_flash_message scheme = params[:scheme]&.to_sym || :danger diff --git a/app/views/work_packages/datepicker_dialog_content.html.erb b/app/views/work_packages/date_picker/show.html.erb similarity index 100% rename from app/views/work_packages/datepicker_dialog_content.html.erb rename to app/views/work_packages/date_picker/show.html.erb diff --git a/config/routes.rb b/config/routes.rb index 53c962fe2fd7..68ef61ba7ad9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -654,7 +654,8 @@ get "/export_dialog" => "work_packages#export_dialog", on: :collection, as: "export_dialog" get :show_conflict_flash_message, on: :collection # we don't need a specific work package for this - get "/datepicker_dialog_content" => "work_packages#datepicker_dialog_content", on: :member, as: "datepicker_dialog_content" + + resource :datepicker_dialog_content, controller: "work_packages/date_picker", on: :member, as: "datepicker_dialog_content" get "/split_view/update_counter" => "work_packages/split_view#update_counter", on: :member diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 9e48dbc96f7e..a2ed1bb60c5c 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -235,6 +235,7 @@ import { CurrentProjectService } from 'core-app/core/current-project/current-pro import { TimeEntriesWorkPackageAutocompleterComponent, } from 'core-app/shared/components/autocompleter/time-entries-work-package-autocompleter/time-entries-work-package-autocompleter.component'; +import { OpWpModalDatePickerComponent } from 'core-app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component'; export function initializeServices(injector:Injector) { return () => { @@ -448,6 +449,7 @@ export class OpenProjectModule implements DoBootstrap { registerCustomElement('opce-timer-account-menu', TimerAccountMenuComponent, { injector }); registerCustomElement('opce-remote-field-updater', RemoteFieldUpdaterComponent, { injector }); registerCustomElement('opce-modal-single-date-picker', OpModalSingleDatePickerComponent, { injector }); + registerCustomElement('opce-wp-modal-date-picker', OpWpModalDatePickerComponent, { injector }); registerCustomElement('opce-spot-drop-modal-portal', SpotDropModalPortalComponent, { injector }); registerCustomElement('opce-spot-switch', SpotSwitchComponent, { injector }); registerCustomElement('opce-modal-overlay', OpModalOverlayComponent, { injector }); diff --git a/frontend/src/app/core/path-helper/path-helper.service.ts b/frontend/src/app/core/path-helper/path-helper.service.ts index 758d1f448e7e..8263a383c591 100644 --- a/frontend/src/app/core/path-helper/path-helper.service.ts +++ b/frontend/src/app/core/path-helper/path-helper.service.ts @@ -308,7 +308,7 @@ export class PathHelperService { return `${this.workPackagePath(workPackageId)}/split_view/get_relations_counter`; } - public workPackageDatepickerDialogContentPath(workPackageId:string|number) { + public workPackageDatepickerDialogContentPath(workPackageId:string|number):string { return `${this.workPackagePath(workPackageId)}/datepicker_dialog_content`; } diff --git a/frontend/src/app/shared/components/datepicker/datepicker.module.ts b/frontend/src/app/shared/components/datepicker/datepicker.module.ts index dfe83bc5ce2b..a059dcefd58e 100644 --- a/frontend/src/app/shared/components/datepicker/datepicker.module.ts +++ b/frontend/src/app/shared/components/datepicker/datepicker.module.ts @@ -15,6 +15,8 @@ import { OpSpotModule } from 'core-app/spot/spot.module'; import { OpenprojectModalModule } from '../modal/modal.module'; import { OpDatePickerSheetComponent } from 'core-app/shared/components/datepicker/sheet/date-picker-sheet.component'; import { OpenprojectContentLoaderModule } from 'core-app/shared/components/op-content-loader/openproject-content-loader.module'; +import { OpWpMultiDateDialogContentComponent } from 'core-app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-dialog-content.component'; +import { OpWpModalDatePickerComponent } from 'core-app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component'; @NgModule({ imports: [ @@ -37,6 +39,8 @@ import { OpenprojectContentLoaderModule } from 'core-app/shared/components/op-co OpWpMultiDateFormComponent, OpWpSingleDateFormComponent, OpDatePickerSheetComponent, + OpWpMultiDateDialogContentComponent, + OpWpModalDatePickerComponent, ], exports: [ @@ -45,6 +49,8 @@ import { OpenprojectContentLoaderModule } from 'core-app/shared/components/op-co OpWpSingleDateFormComponent, OpBasicDatePickerModule, OpDatePickerSheetComponent, + OpWpMultiDateDialogContentComponent, + OpWpModalDatePickerComponent, ], }) export class OpDatePickerModule { } diff --git a/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts b/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts new file mode 100644 index 000000000000..c28c1549d153 --- /dev/null +++ b/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts @@ -0,0 +1,184 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + Injector, + Input, + OnInit, + ViewChild, +} from '@angular/core'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { TimezoneService } from 'core-app/core/datetime/timezone.service'; +import { DayElement } from 'flatpickr/dist/types/instance'; +import flatpickr from 'flatpickr'; +import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; +import { DateModalRelationsService } from 'core-app/shared/components/datepicker/services/date-modal-relations.service'; +import { onDayCreate } from 'core-app/shared/components/datepicker/helpers/date-modal.helpers'; +import { WeekdayService } from 'core-app/core/days/weekday.service'; +import { FocusHelperService } from 'core-app/shared/directives/focus/focus-helper'; +import { DeviceService } from 'core-app/core/browser/device.service'; +import { DatePicker } from '../datepicker'; +import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; +import { populateInputsFromDataset } from 'core-app/shared/components/dataset-inputs'; +import { fromEvent } from 'rxjs'; +import { filter } from 'rxjs/operators'; + +@Component({ + selector: 'op-wp-modal-date-picker', + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: [ + '../styles/datepicker.modal.sass', + ], +}) +export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements AfterViewInit, OnInit { + @Input() public ignoreNonWorkingDays:boolean; + @Input() public scheduleManually:boolean; + + @Input() public startDate:Date; + @Input() public dueDate:Date; + + @Input() public isSchedulable:boolean = true; + @Input() public minimalSchedulingDate:Date|null; + + @Input() fieldName:string = ''; + + @ViewChild('flatpickrTarget') flatpickrTarget:ElementRef; + + private datePickerInstance:DatePicker; + + constructor( + readonly injector:Injector, + readonly cdRef:ChangeDetectorRef, + readonly apiV3Service:ApiV3Service, + readonly I18n:I18nService, + readonly timezoneService:TimezoneService, + readonly deviceService:DeviceService, + readonly weekdayService:WeekdayService, + readonly focusHelper:FocusHelperService, + readonly pathHelper:PathHelperService, + readonly elementRef:ElementRef, + ) { + super(); + populateInputsFromDataset(this); + } + + ngOnInit():void { + //this.setCurrentActivatedField(this.initialActivatedField); + } + + ngAfterViewInit():void { + this.initializeDatepicker(); + } + + private initializeDatepicker() { + this.datePickerInstance?.destroy(); + this.datePickerInstance = new DatePicker( + this.injector, + '#flatpickr-input', + [this.startDate || '', this.dueDate || ''], + { + mode: 'range', + showMonths: this.deviceService.isMobile ? 1 : 2, + inline: true, + onReady: (_date, _datestr, instance) => { + instance.calendarContainer.classList.add('op-datepicker-modal--flatpickr-instance'); + + this.ensureHoveredSelection(instance.calendarContainer); + }, + onChange: (dates:Date[], _datestr, instance) => { + this.startDate = dates[0]; + this.dueDate = dates[1]; + + /*const activeField = this.currentlyActivatedDateField; + + // When two values are passed from datepicker and we don't have duration set, + // just take the range provided by them + if (dates.length === 2 && !this.duration) { + this.setDatesAndDeriveDuration(dates[0], dates[1]); + this.toggleCurrentActivatedField(); + return; + } + + // Update with the same flow as entering a value + const { latestSelectedDateObj } = instance as { latestSelectedDateObj:Date }; + this.datepickerChanged$.next([activeField, latestSelectedDateObj]);*/ + }, + // eslint-disable-next-line @typescript-eslint/no-misused-promises + onDayCreate: async (dObj:Date[], dStr:string, fp:flatpickr.Instance, dayElem:DayElement) => { + onDayCreate( + dayElem, + this.ignoreNonWorkingDays, + await this.datePickerInstance?.isNonWorkingDay(dayElem.dateObj), + this.isDayDisabled(dayElem), + ); + }, + }, + this.flatpickrTarget.nativeElement, + ); + } + + private isDayDisabled(dayElement:DayElement):boolean { + const minimalDate = this.minimalSchedulingDate || null; + return !this.isSchedulable || (!this.scheduleManually && !!minimalDate && dayElement.dateObj <= minimalDate); + } + + /** + * When hovering selections in the range datepicker, the range usually + * stays active no matter where the cursor is. + * + * We want to hide any hovered selection preview when we leave the datepicker. + * @param calendarContainer + * @private + */ + private ensureHoveredSelection(calendarContainer:HTMLDivElement) { + fromEvent(calendarContainer, 'mouseenter') + .pipe( + this.untilDestroyed(), + ) + .subscribe(() => calendarContainer.classList.remove('flatpickr-container-suppress-hover')); + + fromEvent(calendarContainer, 'mouseleave') + .pipe( + this.untilDestroyed(), + filter(() => !(!!this.startDate && !!this.dueDate)), + ) + .subscribe(() => calendarContainer.classList.add('flatpickr-container-suppress-hover')); + } +} diff --git a/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-dialog-content.component.ts b/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-dialog-content.component.ts new file mode 100644 index 000000000000..3432d342d7d4 --- /dev/null +++ b/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-dialog-content.component.ts @@ -0,0 +1,82 @@ +//-- 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 { + ChangeDetectionStrategy, + Component, + HostBinding, + Input, + OnInit, + ViewEncapsulation, +} from '@angular/core'; +import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; +import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset'; + +@Component({ + selector: 'op-wp-multi-date-dialog-content', + template: ` + + + + + + + + + + + + `, + styleUrls: [ + '../styles/datepicker.modal.sass', + ], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, +}) +export class OpWpMultiDateDialogContentComponent extends UntilDestroyedMixin implements OnInit { + @HostBinding('class.op-datepicker-modal') className = true; + + @HostBinding('class.op-datepicker-modal_wide') classNameWide = true; + + @Input() changeset:ResourceChangeset; + + public turboFrameSrc:string; + + constructor( + readonly pathHelper:PathHelperService, + ) { + super(); + } + + ngOnInit():void { + this.turboFrameSrc = this.pathHelper.workPackageDatepickerDialogContentPath(this.changeset.id); + } +} diff --git a/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.html b/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.html index 6e6c66fbde87..626713f1d5b3 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.html +++ b/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.html @@ -20,13 +20,18 @@ (save)="save()" (cancel)="cancel()" > - + + From 06a9c6382ae2feb6a7c9522e7c22b496905459fb Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Wed, 11 Dec 2024 09:34:12 +0100 Subject: [PATCH 05/92] Extract logic to update the edit field after the turboFrame was submitted into a directive so that it can be used for the datepicker as well --- .../dialog_content_component.html.erb | 1 + .../work_packages/date_picker_controller.rb | 24 ++-- .../datepicker/datepicker.module.ts | 3 - .../wp-multi-date-dialog-content.component.ts | 82 ----------- .../combined-date-edit-field.component.html | 23 ++- .../combined-date-edit-field.component.ts | 59 +++++++- ...progress-popover-edit-field.component.html | 9 +- .../progress-popover-edit-field.component.ts | 93 ++---------- .../modal-with-turbo-content.directive.ts | 134 ++++++++++++++++++ .../fields/openproject-fields.module.ts | 3 + 10 files changed, 241 insertions(+), 190 deletions(-) delete mode 100644 frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-dialog-content.component.ts create mode 100644 frontend/src/app/shared/components/fields/edit/modal-with-turbo-content/modal-with-turbo-content.directive.ts diff --git a/app/components/work_packages/date_picker/dialog_content_component.html.erb b/app/components/work_packages/date_picker/dialog_content_component.html.erb index 4e47c7e08ca2..def051400c93 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.html.erb +++ b/app/components/work_packages/date_picker/dialog_content_component.html.erb @@ -65,6 +65,7 @@ end second_row.with_column do render(Primer::Alpha::TextField.new(name: :duration, + type: "number", label: I18n.t("activerecord.attributes.work_package.duration"), value: work_package.duration)) end diff --git a/app/controllers/work_packages/date_picker_controller.rb b/app/controllers/work_packages/date_picker_controller.rb index d25e4078245c..b8837108fd0f 100644 --- a/app/controllers/work_packages/date_picker_controller.rb +++ b/app/controllers/work_packages/date_picker_controller.rb @@ -60,6 +60,8 @@ def show end def update + # Todo + params[:duration] = params[:duration].to_i service_call = WorkPackages::UpdateService .new(user: current_user, model: @work_package) @@ -79,7 +81,7 @@ def update # the request in order to fetch the new set of Work Package # attributes in the ancestry solely on success. render turbo_stream: [ - turbo_stream.morph("work_package_datepicker_modal", datepicker_modal_component) + turbo_stream.morph("wp-datepicker-dialog--content", datepicker_modal_component) ], status: :unprocessable_entity end end @@ -88,7 +90,7 @@ def update private - def progress_modal_component + def datepicker_modal_component WorkPackages::DatePicker::DialogContentComponent.new(work_package: @work_package, manually_scheduled:, focused_field:, touched_field_map:) end @@ -102,8 +104,7 @@ def find_work_package end def touched_field_map - params.require(:work_package) - .slice("schedule_manually_touched", + params.slice("schedule_manually_touched", "ignore_non_working_days_touched", "start_date_touched", "due_date_touched", @@ -121,20 +122,19 @@ def manually_scheduled end def work_package_datepicker_params - params.require(:work_package) - .slice(*allowed_touched_params) + params.slice(*allowed_params) .permit! end - def allowed_touched_params - allowed_params.filter { touched?(_1) } - end + # def allowed_touched_params + # allowed_params.filter { touched?(_1) } + # end def allowed_params %i[schedule_manually ignore_non_working_days start_date due_date duration] end - def touched?(field) - touched_field_map[:"#{field}_touched"] - end + # def touched?(field) + # touched_field_map[:"#{field}_touched"] + # end end diff --git a/frontend/src/app/shared/components/datepicker/datepicker.module.ts b/frontend/src/app/shared/components/datepicker/datepicker.module.ts index a059dcefd58e..aa7f871935c2 100644 --- a/frontend/src/app/shared/components/datepicker/datepicker.module.ts +++ b/frontend/src/app/shared/components/datepicker/datepicker.module.ts @@ -15,7 +15,6 @@ import { OpSpotModule } from 'core-app/spot/spot.module'; import { OpenprojectModalModule } from '../modal/modal.module'; import { OpDatePickerSheetComponent } from 'core-app/shared/components/datepicker/sheet/date-picker-sheet.component'; import { OpenprojectContentLoaderModule } from 'core-app/shared/components/op-content-loader/openproject-content-loader.module'; -import { OpWpMultiDateDialogContentComponent } from 'core-app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-dialog-content.component'; import { OpWpModalDatePickerComponent } from 'core-app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component'; @NgModule({ @@ -39,7 +38,6 @@ import { OpWpModalDatePickerComponent } from 'core-app/shared/components/datepic OpWpMultiDateFormComponent, OpWpSingleDateFormComponent, OpDatePickerSheetComponent, - OpWpMultiDateDialogContentComponent, OpWpModalDatePickerComponent, ], @@ -49,7 +47,6 @@ import { OpWpModalDatePickerComponent } from 'core-app/shared/components/datepic OpWpSingleDateFormComponent, OpBasicDatePickerModule, OpDatePickerSheetComponent, - OpWpMultiDateDialogContentComponent, OpWpModalDatePickerComponent, ], }) diff --git a/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-dialog-content.component.ts b/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-dialog-content.component.ts deleted file mode 100644 index 3432d342d7d4..000000000000 --- a/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-dialog-content.component.ts +++ /dev/null @@ -1,82 +0,0 @@ -//-- copyright -// OpenProject is an open source project management software. -// Copyright (C) the OpenProject GmbH -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License version 3. -// -// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -// Copyright (C) 2006-2013 Jean-Philippe Lang -// Copyright (C) 2010-2013 the ChiliProject Team -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License -// as published by the Free Software Foundation; either version 2 -// of the License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -// -// See COPYRIGHT and LICENSE files for more details. -//++ - -import { - ChangeDetectionStrategy, - Component, - HostBinding, - Input, - OnInit, - ViewEncapsulation, -} from '@angular/core'; -import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; -import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; -import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset'; - -@Component({ - selector: 'op-wp-multi-date-dialog-content', - template: ` - - - - - - - - - - - - `, - styleUrls: [ - '../styles/datepicker.modal.sass', - ], - changeDetection: ChangeDetectionStrategy.OnPush, - encapsulation: ViewEncapsulation.None, -}) -export class OpWpMultiDateDialogContentComponent extends UntilDestroyedMixin implements OnInit { - @HostBinding('class.op-datepicker-modal') className = true; - - @HostBinding('class.op-datepicker-modal_wide') classNameWide = true; - - @Input() changeset:ResourceChangeset; - - public turboFrameSrc:string; - - constructor( - readonly pathHelper:PathHelperService, - ) { - super(); - } - - ngOnInit():void { - this.turboFrameSrc = this.pathHelper.workPackageDatepickerDialogContentPath(this.changeset.id); - } -} diff --git a/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.html b/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.html index 626713f1d5b3..66578feafcae 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.html +++ b/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.html @@ -20,10 +20,25 @@ (save)="save()" (cancel)="cancel()" > - + + + + + + + + + + + + - -
- - - - - - - - - - - -
- - - -
-
- - -
-
- diff --git a/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.ts b/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.ts deleted file mode 100644 index 357015d8c6d8..000000000000 --- a/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.ts +++ /dev/null @@ -1,843 +0,0 @@ -//-- copyright -// OpenProject is an open source project management software. -// Copyright (C) the OpenProject GmbH -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License version 3. -// -// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -// Copyright (C) 2006-2013 Jean-Philippe Lang -// Copyright (C) 2010-2013 the ChiliProject Team -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License -// as published by the Free Software Foundation; either version 2 -// of the License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -// -// See COPYRIGHT and LICENSE files for more details. -//++ - -import { - AfterViewInit, - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - ElementRef, - EventEmitter, - Input, - Injector, - ViewChild, - ViewEncapsulation, - OnInit, - Output, - HostBinding, -} from '@angular/core'; -import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { HalResourceEditingService } from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service'; -import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset'; -import { TimezoneService } from 'core-app/core/datetime/timezone.service'; -import { DayElement } from 'flatpickr/dist/types/instance'; -import flatpickr from 'flatpickr'; -import { - debounce, - debounceTime, - filter, - map, - switchMap, -} from 'rxjs/operators'; -import { - fromEvent, - merge, - Observable, - Subject, - timer, -} from 'rxjs'; -import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; -import { FormResource } from 'core-app/features/hal/resources/form-resource'; -import { DateModalRelationsService } from 'core-app/shared/components/datepicker/services/date-modal-relations.service'; -import { - areDatesEqual, - mappedDate, - onDayCreate, - parseDate, - setDates, - validDate, -} from 'core-app/shared/components/datepicker/helpers/date-modal.helpers'; -import { WeekdayService } from 'core-app/core/days/weekday.service'; -import { FocusHelperService } from 'core-app/shared/directives/focus/focus-helper'; -import { DeviceService } from 'core-app/core/browser/device.service'; -import { DatePicker } from '../datepicker'; - -import DateOption = flatpickr.Options.DateOption; -import { WorkPackageChangeset } from 'core-app/features/work-packages/components/wp-edit/work-package-changeset'; -import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; -import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; -import { DateModalSchedulingService } from '../services/date-modal-scheduling.service'; -import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; - -export type DateKeys = 'start'|'end'; -export type DateFields = DateKeys|'duration'; - -export type StartUpdate = { startDate:string }; -export type EndUpdate = { dueDate:string }; -export type DurationUpdate = { duration:string|number|null }; -export type DateUpdate = { date:string }; -export type ActiveDateChange = [DateFields, null|Date|Date]; - -export type FieldUpdates = - StartUpdate - |EndUpdate - |(StartUpdate&EndUpdate) - |(StartUpdate&DurationUpdate) - |(EndUpdate&DurationUpdate) - |DateUpdate; - -@Component({ - selector: 'op-wp-multi-date-form', - templateUrl: './wp-multi-date-form.component.html', - styleUrls: [ - '../styles/datepicker.modal.sass', - ], - changeDetection: ChangeDetectionStrategy.OnPush, - encapsulation: ViewEncapsulation.None, - providers: [ - DateModalRelationsService, - DateModalSchedulingService, - ], -}) -export class OpWpMultiDateFormComponent extends UntilDestroyedMixin implements AfterViewInit, OnInit { - @HostBinding('class.op-datepicker-modal') className = true; - - @HostBinding('class.op-datepicker-modal_wide') classNameWide = true; - - @ViewChild('modalContainer') modalContainer:ElementRef; - - @ViewChild('durationField', { read: ElementRef }) durationField:ElementRef; - - @ViewChild('flatpickrTarget') flatpickrTarget:ElementRef; - - @Input() changeset:ResourceChangeset; - - @Input() fieldName:string = ''; - - @Output() cancel = new EventEmitter(); - - @Output() save = new EventEmitter(); - - public turboFrameSrc:string; - - text = { - save: this.I18n.t('js.button_save'), - cancel: this.I18n.t('js.button_cancel'), - startDate: this.I18n.t('js.work_packages.properties.startDate'), - endDate: this.I18n.t('js.work_packages.properties.dueDate'), - duration: this.I18n.t('js.work_packages.properties.duration'), - placeholder: this.I18n.t('js.placeholders.default'), - today: this.I18n.t('js.label_today'), - days: (count:number):string => this.I18n.t('js.units.day', { count }), - }; - - scheduleManually = false; - - ignoreNonWorkingDays = false; - - duration:number|null; - - currentlyActivatedDateField:DateFields; - - htmlId = ''; - - dates:{ [key in DateKeys]:string|null } = { - start: null, - end: null, - }; - - // Manual changes from the inputs to start and end dates - startDateChanged$ = new Subject(); - - startDateDebounced$:Observable = this.debouncedInput(this.startDateChanged$, 'start'); - - endDateChanged$ = new Subject(); - - endDateDebounced$:Observable = this.debouncedInput(this.endDateChanged$, 'end'); - - // Manual changes to the datepicker, with information which field was active - datepickerChanged$ = new Subject(); - - // We want to position the modal as soon as the datepicker gets initialized - // But if we destroy and recreate the datepicker (e.g., when toggling switches), keep current position - modalPositioned = false; - - // Date updates from the datepicker or a manual change - dateUpdates$ = merge( - this.startDateDebounced$, - this.endDateDebounced$, - this.datepickerChanged$, - ) - .pipe( - this.untilDestroyed(), - filter(() => !!this.datePickerInstance), - ) - .subscribe(([field, update]) => { - // When clearing the one date, clear the others as well - if (update !== null) { - this.handleSingleDateUpdate(field, update); - } - - // Clear active field and duration - // when the active field was cleared - if (update === null && field !== 'duration') { - this.clearWithDuration(field); - } - - // The duration field is special in how it handles focus transitions - // For start/due we just toggle here - if (update !== null && field !== 'duration') { - this.toggleCurrentActivatedField(); - } - - this.cdRef.detectChanges(); - }); - - // Duration changes - durationChanges$ = new Subject(); - - durationDebounced$ = this - .durationChanges$ - .pipe( - this.untilDestroyed(), - debounce((value) => (value ? timer(500) : timer(0))), - map((value) => (value === '' ? null : Math.abs(parseInt(value, 10)))), - filter((val) => val === null || !Number.isNaN(val)), - filter((val) => val !== this.duration), - ) - .subscribe((value) => this.applyDurationChange(value)); - - // Duration is a special field as it changes its value based on its focus state - // which is different from the highlight state... - durationFocused = false; - - ignoreNonWorkingDaysWritable = true; - - private datePickerInstance:DatePicker; - - private formUpdates$ = new Subject(); - - private minimalSchedulingDate:Date|null = null; - - constructor( - readonly injector:Injector, - readonly cdRef:ChangeDetectorRef, - readonly apiV3Service:ApiV3Service, - readonly I18n:I18nService, - readonly timezoneService:TimezoneService, - readonly halEditing:HalResourceEditingService, - readonly dateModalScheduling:DateModalSchedulingService, - readonly dateModalRelations:DateModalRelationsService, - readonly deviceService:DeviceService, - readonly weekdayService:WeekdayService, - readonly focusHelper:FocusHelperService, - readonly pathHelper:PathHelperService, - ) { - super(); - - this - .formUpdates$ - .pipe( - this.untilDestroyed(), - switchMap((fieldsToUpdate:FieldUpdates) => this - .apiV3Service - .work_packages - .withOptionalId(this.changeset.id === 'new' ? null : this.changeset.id) - .form - .forPayload({ - ...fieldsToUpdate, - lockVersion: this.changeset.value('lockVersion'), - ignoreNonWorkingDays: this.ignoreNonWorkingDays, - scheduleManually: this.scheduleManually, - })), - ) - .subscribe((form) => this.updateDatesFromForm(form)); - } - - ngOnInit(): void { - this.htmlId = `wp-datepicker-${this.fieldName as string}`; - - this.dateModalScheduling.setChangeset(this.changeset as WorkPackageChangeset); - this.dateModalRelations.setChangeset(this.changeset as WorkPackageChangeset); - - this.scheduleManually = !!this.changeset.value('scheduleManually'); - this.ignoreNonWorkingDays = !!this.changeset.value('ignoreNonWorkingDays'); - - // Ensure we get the writable values from the loaded form - void this - .changeset - .getForm() - .then((form) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - this.ignoreNonWorkingDaysWritable = !!form.schema.ignoreNonWorkingDays.writable; - this.cdRef.detectChanges(); - }); - - this.setDurationDaysFromUpstream(this.changeset.value('duration')); - - this.dates.start = this.changeset.value('startDate'); - this.dates.end = this.changeset.value('dueDate'); - this.setCurrentActivatedField(this.initialActivatedField); - - this.turboFrameSrc = this.pathHelper.workPackageDatepickerDialogContentPath(this.changeset.id); - } - - ngAfterViewInit():void { - const init = () => { - this.initializeDatepicker(); - - // Autofocus duration if that's what activated us - if (this.initialActivatedField === 'duration') { - this.focusHelper.focus(this.durationField.nativeElement); - } - }; - - if (isNewResource(this.changeset.pristineResource)) { - init(); - return; - } - - this - .dateModalRelations - .getMinimalDateFromPreceeding() - .subscribe((date) => { - this.minimalSchedulingDate = date; - init(); - }); - } - - changeSchedulingMode():void { - // If removing manual scheduling on parent, reset ignoreNWD to original value - if (this.scheduleManually === false && !this.ignoreNonWorkingDaysWritable) { - this.ignoreNonWorkingDays = !!this.changeset.value('ignoreNonWorkingDays'); - } - - this.datePickerInstance?.datepickerInstance.redraw(); - this.cdRef.detectChanges(); - } - - changeNonWorkingDays():void { - this.datePickerInstance?.datepickerInstance.redraw(); - - // Resent the current start and duration so that the end date is calculated - if (!!this.dates.start && !!this.duration) { - this.formUpdates$.next({ startDate: this.dates.start, duration: this.durationAsIso8601 }); - } - - // If only one of the dates is set, sent that - // Resent the current start and duration so that the end date is calculated - if (!!this.dates.start && !this.dates.end) { - this.formUpdates$.next({ startDate: this.dates.start }); - } - - if (!!this.dates.end && !this.dates.start) { - this.formUpdates$.next({ dueDate: this.dates.end }); - } - - this.cdRef.detectChanges(); - } - - doSave($event:Event):void { - $event.preventDefault(); - // Apply the changed scheduling mode if any - this.changeset.setValue('scheduleManually', this.scheduleManually); - - // Apply include NWD - this.changeset.setValue('ignoreNonWorkingDays', this.ignoreNonWorkingDays); - - // Apply the dates if they could be changed - if (this.isSchedulable) { - this.changeset.setValue('startDate', mappedDate(this.dates.start)); - this.changeset.setValue('dueDate', mappedDate(this.dates.end)); - this.changeset.setValue('duration', this.durationAsIso8601); - } - - this.save.emit(); - } - - doCancel():void { - this.cancel.emit(); - } - - updateDate(key:DateKeys, val:string|null):void { - if ((val === null || validDate(val)) && this.datePickerInstance) { - this.dates[key] = mappedDate(val); - const dateValue = parseDate(val || '') || undefined; - this.enforceManualChangesToDatepicker(dateValue); - this.cdRef.detectChanges(); - } - } - - setCurrentActivatedField(val:DateFields):void { - this.currentlyActivatedDateField = val; - } - - toggleCurrentActivatedField():void { - this.currentlyActivatedDateField = this.currentlyActivatedDateField === 'start' ? 'end' : 'start'; - } - - isStateOfCurrentActivatedField(val:DateFields):boolean { - return this.currentlyActivatedDateField === val; - } - - setToday(key:DateKeys):void { - this.datepickerChanged$.next([key, new Date()]); - } - - showTodayLink():boolean { - return this.isSchedulable; - } - - /** - * Returns whether the user can alter the dates of the work package. - */ - get isSchedulable():boolean { - return this.scheduleManually || !this.dateModalRelations.isParent; - } - - showFieldAsActive(field:DateFields):boolean { - return this.isStateOfCurrentActivatedField(field) && this.isSchedulable; - } - - handleDurationFocusIn():void { - this.durationFocused = true; - this.setCurrentActivatedField('duration'); - } - - handleDurationFocusOut():void { - setTimeout(() => { - this.durationFocused = false; - this.cdRef.detectChanges(); - }); - } - - get displayedDuration():string { - if (!this.duration) { - return ''; - } - - return this.text.days(this.duration); - } - - private applyDurationChange(newValue:number|null):void { - this.duration = newValue; - this.cdRef.detectChanges(); - - // If we cleared duration or left it empty - // reset the value and the due date - if (newValue === null) { - this.updateDate('end', null); - return; - } - - if (this.dates.start) { - this.formUpdates$.next({ - startDate: this.dates.start, - duration: this.durationAsIso8601, - }); - } else if (this.dates.end) { - this.formUpdates$.next({ - dueDate: this.dates.end, - duration: this.durationAsIso8601, - }); - } - } - - private get durationAsIso8601():string|null { - if (this.duration) { - return this.timezoneService.toISODuration(this.duration, 'days'); - } - - return null; - } - - private clearWithDuration(field:DateKeys) { - this.duration = null; - this.dates[field] = null; - this.enforceManualChangesToDatepicker(); - } - - private initializeDatepicker() { - this.datePickerInstance?.destroy(); - this.datePickerInstance = new DatePicker( - this.injector, - '#flatpickr-input', - [this.dates.start || '', this.dates.end || ''], - { - mode: 'range', - showMonths: this.deviceService.isMobile ? 1 : 2, - inline: true, - onReady: (_date, _datestr, instance) => { - instance.calendarContainer.classList.add('op-datepicker-modal--flatpickr-instance'); - - if (!this.modalPositioned) { - this.modalPositioned = true; - } - - this.ensureHoveredSelection(instance.calendarContainer); - }, - onChange: (dates:Date[], _datestr, instance) => { - const activeField = this.currentlyActivatedDateField; - - // When two values are passed from datepicker and we don't have duration set, - // just take the range provided by them - if (dates.length === 2 && !this.duration) { - this.setDatesAndDeriveDuration(dates[0], dates[1]); - this.toggleCurrentActivatedField(); - return; - } - - // Update with the same flow as entering a value - const { latestSelectedDateObj } = instance as { latestSelectedDateObj:Date }; - this.datepickerChanged$.next([activeField, latestSelectedDateObj]); - }, - // eslint-disable-next-line @typescript-eslint/no-misused-promises - onDayCreate: async (dObj:Date[], dStr:string, fp:flatpickr.Instance, dayElem:DayElement) => { - onDayCreate( - dayElem, - this.ignoreNonWorkingDays, - await this.datePickerInstance?.isNonWorkingDay(dayElem.dateObj), - this.isDayDisabled(dayElem, this.minimalSchedulingDate), - ); - }, - }, - this.flatpickrTarget.nativeElement, - ); - } - - private enforceManualChangesToDatepicker(enforceDate?:Date) { - let startDate = parseDate(this.dates.start || ''); - let endDate = parseDate(this.dates.end || ''); - - if (startDate && endDate) { - // If the start date is manually changed to be after the end date, - // we adjust the end date to be at least the same as the newly entered start date. - // Same applies if the end date is set manually before the current start date - if (startDate > endDate && this.isStateOfCurrentActivatedField('start')) { - endDate = startDate; - this.dates.end = this.timezoneService.formattedISODate(endDate); - } else if (endDate < startDate && this.isStateOfCurrentActivatedField('end')) { - startDate = endDate; - this.dates.start = this.timezoneService.formattedISODate(startDate); - } - } - - const dates = [startDate, endDate]; - setDates(dates, this.datePickerInstance, enforceDate); - } - - private setDatesAndDeriveDuration(newStart:Date, newEnd:Date) { - this.dates.start = this.timezoneService.formattedISODate(newStart); - this.dates.end = this.timezoneService.formattedISODate(newEnd); - - // Derive duration - this.formUpdates$.next({ startDate: this.dates.start, dueDate: this.dates.end }); - } - - private handleSingleDateUpdate(activeField:DateFields, selectedDate:Date) { - if (activeField === 'duration') { - this.durationActiveDateSelected(selectedDate); - return; - } - - // If both dates are now set, ensure we update it accordingly - if (this.dates.start && this.dates.end) { - this.replaceDatesWithNewSelection(activeField, selectedDate); - return; - } - - // Set the current date field - this.moveActiveDate(activeField, selectedDate); - - // We may or may not have both fields set now - // If we have duration set, we derive the other field - if (this.duration) { - this.deriveMissingDateFromDuration(activeField); - } else if (this.dates.start && this.dates.end) { - this.formUpdates$.next({ startDate: this.dates.start, dueDate: this.dates.end }); - } - - // Set the selected date on the datepicker - this.enforceManualChangesToDatepicker(selectedDate); - } - - /** - * The duration field is active and a date was clicked in the datepicker. - * - * If the duration field has a value: - * - start date is updated, derive end date, set end date active - * If the duration field has no value: - * - If start date has a value, finish date is set - * - Otherwise, start date is set - * - Focus is set to the finish date - * - * @param selectedDate The date selected - * @private - */ - private durationActiveDateSelected(selectedDate:Date) { - const selectedIsoDate = this.timezoneService.formattedISODate(selectedDate); - - if (!this.duration && this.dates.start) { - // When duration is empty and start is set, update finish - this.setDaysInOrder(this.dates.start, selectedIsoDate); - - // Focus moves to start date - this.setCurrentActivatedField('start'); - } else { - // Otherwise, the start date always gets updated - this.setDaysInOrder(selectedIsoDate, this.dates.end); - - // Focus moves to finish date - this.setCurrentActivatedField('end'); - } - - if (this.dates.start && this.duration) { - // If duration has value, derive end date from start and duration - this.formUpdates$.next({ startDate: this.dates.start, duration: this.durationAsIso8601 }); - } else if (this.dates.start && this.dates.end) { - // If start and due now have values, derive duration again - this.formUpdates$.next({ startDate: this.dates.start, dueDate: this.dates.end }); - } - } - - private setDaysInOrder(start:string|null, end:string|null) { - const parsedStartDate = start ? parseDate(start) as Date : null; - const parsedEndDate = end ? parseDate(end) as Date : null; - - if (parsedStartDate && parsedEndDate && parsedStartDate > parsedEndDate) { - this.dates.start = end; - this.dates.end = start; - } else { - this.dates.start = start; - this.dates.end = end; - } - } - - /** - * The active field was updated in the datepicker, while the other date was not set - * - * This means we want to derive the non-active field using the duration, if that is set. - * - * @param activeField The active field that was changed - * @private - */ - private deriveMissingDateFromDuration(activeField:'start'|'end') { - if (activeField === 'start' && !!this.dates.start) { - this.formUpdates$.next({ startDate: this.dates.start, duration: this.durationAsIso8601 }); - } - - if (activeField === 'end' && !!this.dates.end) { - this.formUpdates$.next({ dueDate: this.dates.end, duration: this.durationAsIso8601 }); - } - } - - /** - * Moves the active date to the given selected date. - * - * This is different from replaceDatesWithNewSelection as duration is prioritized higher in our case. - * @param activeField - * @param selectedDate - * @private - */ - private moveActiveDate(activeField:DateKeys, selectedDate:Date) { - const parsedStartDate = this.dates.start ? parseDate(this.dates.start) as Date : null; - const parsedEndDate = this.dates.end ? parseDate(this.dates.end) as Date : null; - - // Set the given field - this.dates[activeField] = this.timezoneService.formattedISODate(selectedDate); - - // Special handling, moving finish date to before start date - if (activeField === 'end' && parsedStartDate && parsedStartDate > selectedDate) { - // Reset duration and start date - this.duration = null; - this.dates.start = null; - // Update finish date and mark as active in datepicker - this.enforceManualChangesToDatepicker(selectedDate); - } - - // Special handling, moving start date to after finish date - if (activeField === 'start' && parsedEndDate && parsedEndDate < selectedDate) { - // Reset duration and start date - this.duration = null; - this.dates.end = null; - // Update finish date and mark as active in datepicker - this.enforceManualChangesToDatepicker(selectedDate); - } - } - - private replaceDatesWithNewSelection(activeField:DateFields, selectedDate:Date) { - /** - Overwrite flatpickr default behavior by not starting a new date range everytime but preserving either start or end date. - There are three cases to cover. - 1. Everything before the current start date will become the new start date (independent of the active field) - 2. Everything after the current end date will become the new end date if that is the currently active field. - If the active field is the start date, the selected date becomes the new start date and the end date is cleared. - 3. Everything in between the current start and end date is dependent on the currently activated field. - * */ - - const parsedStartDate = parseDate(this.dates.start || '') as Date; - const parsedEndDate = parseDate(this.dates.end || '') as Date; - - if (selectedDate < parsedStartDate) { - if (activeField === 'start') { - // Set start, derive end from duration - this.applyNewDates([selectedDate]); - } else { - // Reset duration and end date - this.duration = null; - this.applyNewDates(['', selectedDate]); - } - } else if (selectedDate > parsedEndDate) { - if (activeField === 'end') { - this.applyNewDates([parsedStartDate, selectedDate]); - } else { - // Reset duration and end date - this.duration = null; - this.applyNewDates([selectedDate]); - } - } else if (areDatesEqual(selectedDate, parsedStartDate) || areDatesEqual(selectedDate, parsedEndDate)) { - this.applyNewDates([selectedDate, selectedDate]); - } else { - const newDates = activeField === 'start' ? [selectedDate, parsedEndDate] : [parsedStartDate, selectedDate]; - this.applyNewDates(newDates); - } - } - - private applyNewDates([start, end]:DateOption[]) { - this.dates.start = start ? this.timezoneService.formattedISODate(start) : null; - this.dates.end = end ? this.timezoneService.formattedISODate(end) : null; - - // Apply the dates to the datepicker - setDates([start, end], this.datePickerInstance); - - // We updated either start, end, or both fields - // If both are now set, we want to derive duration from them - if (this.dates.start && this.dates.end) { - this.formUpdates$.next({ startDate: this.dates.start, dueDate: this.dates.end }); - } - - // If only one is set, derive from duration - if (this.dates.start && !this.dates.end && !!this.duration) { - this.formUpdates$.next({ startDate: this.dates.start, duration: this.durationAsIso8601 }); - } - - if (this.dates.end && !this.dates.start && !!this.duration) { - this.formUpdates$.next({ dueDate: this.dates.end, duration: this.durationAsIso8601 }); - } - } - - private get initialActivatedField():DateFields { - switch (this.fieldName) { - case 'startDate': - return 'start'; - case 'dueDate': - return 'end'; - case 'duration': - return 'duration'; - default: - return (this.dates.start && !this.dates.end) ? 'end' : 'start'; - } - } - - private isDayDisabled(dayElement:DayElement, minimalDate?:Date|null):boolean { - return !this.isSchedulable || (!this.scheduleManually && !!minimalDate && dayElement.dateObj <= minimalDate); - } - - /** - * Update the datepicker dates and properties from a form response - * that includes derived/calculated values. - * - * @param form - * @private - */ - private updateDatesFromForm(form:FormResource):void { - const payload = form.payload as { startDate:string, dueDate:string, duration:string, ignoreNonWorkingDays:boolean }; - this.dates.start = payload.startDate; - this.dates.end = payload.dueDate; - this.ignoreNonWorkingDays = payload.ignoreNonWorkingDays; - - this.setDurationDaysFromUpstream(payload.duration); - - const parsedStartDate = parseDate(this.dates.start) as Date; - this.enforceManualChangesToDatepicker(parsedStartDate); - this.cdRef.detectChanges(); - } - - /** - * Updates the duration property and the displayed value - * @param value a ISO8601 duration string or null - * @private - */ - private setDurationDaysFromUpstream(value:string|null) { - const durationDays = value ? this.timezoneService.toDays(value) : null; - - if (!durationDays || durationDays === 0) { - this.duration = null; - } else { - this.duration = durationDays; - } - } - - private debouncedInput(input$:Subject, key:DateKeys):Observable { - return input$ - .pipe( - this.untilDestroyed(), - // Skip values that are already set as the current model - filter((value) => value !== this.dates[key]), - // Avoid that the manual changes are moved to the datepicker too early. - // The debounce is chosen quite large on purpose to catch the following case: - // 1. Start date is for example 2022-07-15. The user wants to set the end date to the 19th. - // 2. So he/she starts entering the finish date 2022-07-1 . - // 3. This is already a valid date. Since it is before the start date,the start date would be changed automatically to the first without the debounce. - // 4. The debounce gives the user enough time to type the last number "9" before the changes are converted to the datepicker and the start date would be affected. - debounceTime(500), - filter((date) => validDate(date)), - map((date) => { - if (date === '') { - return null; - } - - return parseDate(date) as Date; - }), - map((date) => [key, date]), - ); - } - - /** - * When hovering selections in the range datepicker, the range usually - * stays active no matter where the cursor is. - * - * We want to hide any hovered selection preview when we leave the datepicker. - * @param calendarContainer - * @private - */ - private ensureHoveredSelection(calendarContainer:HTMLDivElement) { - fromEvent(calendarContainer, 'mouseenter') - .pipe( - this.untilDestroyed(), - ) - .subscribe(() => calendarContainer.classList.remove('flatpickr-container-suppress-hover')); - - fromEvent(calendarContainer, 'mouseleave') - .pipe( - this.untilDestroyed(), - filter(() => !(!!this.dates.start && !!this.dates.end)), - ) - .subscribe(() => calendarContainer.classList.add('flatpickr-container-suppress-hover')); - } -} diff --git a/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.html b/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.html index 389e152c51f4..6b97748c11f0 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.html +++ b/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.html @@ -28,7 +28,7 @@ [resource]="resource" (successfulCreate)="handleSuccessfulCreate($event)" (successfulUpdate)="handleSuccessfulUpdate()" - (cancel)="onModalClosed()" + (cancel)="cancel()" id="wp-datepicker-dialog--content"> @@ -40,14 +40,5 @@ - - diff --git a/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.ts index 307b2cdf7663..1060a96c101a 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.ts @@ -56,8 +56,6 @@ export class CombinedDateEditFieldComponent extends DatePickerEditFieldComponent opened = false; - turboFrameSrc:string; - text = { placeholder: { startDate: this.I18n.t('js.label_no_start_date'), @@ -66,22 +64,8 @@ export class CombinedDateEditFieldComponent extends DatePickerEditFieldComponent }, }; - constructor( - readonly I18n:I18nService, - readonly elementRef:ElementRef, - @Inject(OpEditingPortalChangesetToken) protected change:ResourceChangeset, - @Inject(OpEditingPortalSchemaToken) public schema:IFieldSchema, - @Inject(OpEditingPortalHandlerToken) readonly handler:EditFieldHandler, - readonly cdRef:ChangeDetectorRef, - readonly injector:Injector, - readonly pathHelper:PathHelperService, - ) { - super(I18n, elementRef, change, schema, handler, cdRef, injector); - } - ngOnInit() { super.ngOnInit(); - this.turboFrameSrc = `${this.pathHelper.workPackageDatepickerDialogContentPath(this.change.id)}?field=${this.name}`; } get isMultiDate():boolean { @@ -105,23 +89,12 @@ export class CombinedDateEditFieldComponent extends DatePickerEditFieldComponent this.resetDates(); } - public handleSuccessfulCreate(JSONResponse:{ duration:number, startDate:Date, dueDate:Date, includeNonWorkingDays:boolean, scheduleManually:boolean }):void { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment - this.resource.duration = JSONResponse.duration; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment - this.resource.dueDate = JSONResponse.dueDate; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment - this.resource.startDate = JSONResponse.startDate; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment - this.resource.includeNonWorkingDays = JSONResponse.includeNonWorkingDays; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment - this.resource.scheduleManually = JSONResponse.scheduleManually; - - this.onModalClosed(); + public save():void { + this.handler.handleUserSubmit(); } - public handleSuccessfulUpdate():void { - this.onModalClosed(); + public cancel():void { + this.handler.reset(); } // Overwrite super in order to set the initial dates. diff --git a/frontend/src/app/shared/components/fields/edit/field-types/date-picker-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/date-picker-edit-field.component.ts index a08dfa29ab44..0b2a2e833abd 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/date-picker-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/date-picker-edit-field.component.ts @@ -31,11 +31,25 @@ import { OnDestroy, OnInit, Injector, + ElementRef, + Inject, + ChangeDetectorRef, } from '@angular/core'; import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; import { TimezoneService } from 'core-app/core/datetime/timezone.service'; -import { EditFieldComponent } from 'core-app/shared/components/fields/edit/edit-field.component'; +import { + EditFieldComponent, + OpEditingPortalChangesetToken, + OpEditingPortalHandlerToken, + OpEditingPortalSchemaToken, +} from 'core-app/shared/components/fields/edit/edit-field.component'; import { DeviceService } from 'core-app/core/browser/device.service'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset'; +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { IFieldSchema } from 'core-app/shared/components/fields/field.base'; +import { EditFieldHandler } from 'core-app/shared/components/fields/edit/editing-portal/edit-field-handler'; @Directive() export abstract class DatePickerEditFieldComponent extends EditFieldComponent implements OnInit, OnDestroy { @@ -43,10 +57,24 @@ export abstract class DatePickerEditFieldComponent extends EditFieldComponent im @InjectField() deviceService:DeviceService; - @InjectField() injector:Injector; + turboFrameSrc:string; + + constructor( + readonly I18n:I18nService, + readonly elementRef:ElementRef, + @Inject(OpEditingPortalChangesetToken) protected change:ResourceChangeset, + @Inject(OpEditingPortalSchemaToken) public schema:IFieldSchema, + @Inject(OpEditingPortalHandlerToken) readonly handler:EditFieldHandler, + readonly cdRef:ChangeDetectorRef, + readonly injector:Injector, + readonly pathHelper:PathHelperService, + ) { + super(I18n, elementRef, change, schema, handler, cdRef, injector); + } ngOnInit():void { super.ngOnInit(); + this.turboFrameSrc = `${this.pathHelper.workPackageDatepickerDialogContentPath(this.change.id)}?field=${this.name}`; this.handler .$onUserActivate @@ -63,4 +91,25 @@ export abstract class DatePickerEditFieldComponent extends EditFieldComponent im } public showDatePickerModal():void { } + + public handleSuccessfulCreate(JSONResponse:{ duration:number, startDate:Date, dueDate:Date, includeNonWorkingDays:boolean, scheduleManually:boolean }):void { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.duration = JSONResponse.duration; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.dueDate = JSONResponse.dueDate; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.startDate = JSONResponse.startDate; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.includeNonWorkingDays = JSONResponse.includeNonWorkingDays; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.scheduleManually = JSONResponse.scheduleManually; + + this.onModalClosed(); + } + + public handleSuccessfulUpdate():void { + this.onModalClosed(); + } + + public onModalClosed():void { } } diff --git a/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.html b/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.html index 1e64d5f96dfb..316ecf8f304e 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.html +++ b/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.html @@ -13,12 +13,25 @@ disabled="disabled" [id]="handler.htmlId" /> - - + + + + + + + + + + + + + diff --git a/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.ts index 6545320ea559..d3ece8129151 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.ts @@ -28,16 +28,32 @@ import { ChangeDetectionStrategy, + ChangeDetectorRef, Component, + ElementRef, + Inject, + Injector, + OnInit, } from '@angular/core'; import { DatePickerEditFieldComponent } from 'core-app/shared/components/fields/edit/field-types/date-picker-edit-field.component'; import * as moment from 'moment-timezone'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { + OpEditingPortalChangesetToken, + OpEditingPortalHandlerToken, + OpEditingPortalSchemaToken, +} from 'core-app/shared/components/fields/edit/edit-field.component'; +import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset'; +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { IFieldSchema } from 'core-app/shared/components/fields/field.base'; +import { EditFieldHandler } from 'core-app/shared/components/fields/edit/editing-portal/edit-field-handler'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; @Component({ templateUrl: './days-duration-edit-field.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class DaysDurationEditFieldComponent extends DatePickerEditFieldComponent { +export class DaysDurationEditFieldComponent extends DatePickerEditFieldComponent implements OnInit{ opened = false; public get formattedValue():number { @@ -56,7 +72,7 @@ export class DaysDurationEditFieldComponent extends DatePickerEditFieldComponent this.opened = true; } - save() { + onModalClosed() { this.handler.handleUserSubmit(); this.opened = false; } From 769f3440066398bb3209a9f89d5415c29946bd7d Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Wed, 18 Dec 2024 08:59:29 +0100 Subject: [PATCH 21/92] Hack some roundtrip between the datpicker and the fields above (wip) --- .../dialog_content_component.html.erb | 5 +- .../wp-modal-date-picker.component.ts | 60 +++++++++++-------- 2 files changed, 38 insertions(+), 27 deletions(-) diff --git a/app/components/work_packages/date_picker/dialog_content_component.html.erb b/app/components/work_packages/date_picker/dialog_content_component.html.erb index 4f935d071a92..1dbb88764764 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.html.erb +++ b/app/components/work_packages/date_picker/dialog_content_component.html.erb @@ -87,7 +87,10 @@ due_date: work_package.due_date, ignore_non_working_days: work_package.ignore_non_working_days, schedule_manually:, - is_schedulable: !disabled? + is_schedulable: !disabled?, + field_name: focused_field, + start_date_field_id: "work_package_start_date", + due_date_field_id: "work_package_due_date" } end end diff --git a/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts b/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts index 89213384deea..b6a8a2f29442 100644 --- a/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts +++ b/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts @@ -34,7 +34,6 @@ import { ElementRef, Injector, Input, - OnInit, ViewChild, } from '@angular/core'; import { I18nService } from 'core-app/core/i18n/i18n.service'; @@ -42,10 +41,7 @@ import { TimezoneService } from 'core-app/core/datetime/timezone.service'; import { DayElement } from 'flatpickr/dist/types/instance'; import flatpickr from 'flatpickr'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; -import { DateModalRelationsService } from 'core-app/shared/components/datepicker/services/date-modal-relations.service'; import { onDayCreate } from 'core-app/shared/components/datepicker/helpers/date-modal.helpers'; -import { WeekdayService } from 'core-app/core/days/weekday.service'; -import { FocusHelperService } from 'core-app/shared/directives/focus/focus-helper'; import { DeviceService } from 'core-app/core/browser/device.service'; import { DatePicker } from '../datepicker'; import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; @@ -67,7 +63,7 @@ import { filter } from 'rxjs/operators'; '../styles/datepicker.modal.sass', ], }) -export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements AfterViewInit, OnInit { +export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements AfterViewInit { @Input() public ignoreNonWorkingDays:boolean; @Input() public scheduleManually:boolean; @@ -77,7 +73,9 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements @Input() public isSchedulable:boolean = true; @Input() public minimalSchedulingDate:Date|null; - @Input() fieldName:string = ''; + @Input() fieldName:'start_date'|'due_date' = 'start_date'; + @Input() startDateFieldId:string; + @Input() dueDateFieldId:string; @ViewChild('flatpickrTarget') flatpickrTarget:ElementRef; @@ -90,8 +88,6 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements readonly I18n:I18nService, readonly timezoneService:TimezoneService, readonly deviceService:DeviceService, - readonly weekdayService:WeekdayService, - readonly focusHelper:FocusHelperService, readonly pathHelper:PathHelperService, readonly elementRef:ElementRef, ) { @@ -99,10 +95,6 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements populateInputsFromDataset(this); } - ngOnInit():void { - //this.setCurrentActivatedField(this.initialActivatedField); - } - ngAfterViewInit():void { this.initializeDatepicker(); @@ -152,22 +144,38 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements this.ensureHoveredSelection(instance.calendarContainer); }, onChange: (dates:Date[], _datestr, instance) => { - this.startDate = dates[0]; - this.dueDate = dates[1]; - - /*const activeField = this.currentlyActivatedDateField; - - // When two values are passed from datepicker and we don't have duration set, - // just take the range provided by them - if (dates.length === 2 && !this.duration) { - this.setDatesAndDeriveDuration(dates[0], dates[1]); - this.toggleCurrentActivatedField(); - return; + // Todo: Make code better + if (this.fieldName === 'due_date') { + this.dueDate = dates[0]; + + if (this.dueDateFieldId) { + const dueDateField = document.getElementById(this.dueDateFieldId) as HTMLInputElement; + dueDateField.value = this.timezoneService.formattedISODate(this.dueDate); + dueDateField.dispatchEvent(new Event('input')); + } + + // Toggle the active field + if (this.startDateFieldId) { + document.getElementById(this.startDateFieldId)?.focus(); + } + this.fieldName = 'start_date'; + } else { + this.startDate = dates[0]; + + if (this.startDateFieldId) { + const dueDateField = document.getElementById(this.startDateFieldId) as HTMLInputElement; + dueDateField.value = this.timezoneService.formattedISODate(this.startDate); + dueDateField.dispatchEvent(new Event('input')); + } + + // Toggle the active field + if (this.dueDateFieldId) { + document.getElementById(this.dueDateFieldId)?.focus(); + } + this.fieldName = 'due_date'; } - // Update with the same flow as entering a value - const { latestSelectedDateObj } = instance as { latestSelectedDateObj:Date }; - this.datepickerChanged$.next([activeField, latestSelectedDateObj]);*/ + instance.setDate([this.startDate, this.dueDate]); }, // eslint-disable-next-line @typescript-eslint/no-misused-promises onDayCreate: async (dObj:Date[], dStr:string, fp:flatpickr.Instance, dayElem:DayElement) => { From 92e4c49ffc223799714bc328955750fd6b06d356 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Thu, 19 Dec 2024 09:18:02 +0100 Subject: [PATCH 22/92] Allow duration to be editable in automatic mode --- app/forms/work_packages/date_picker/form.rb | 24 +++------------------ 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/app/forms/work_packages/date_picker/form.rb b/app/forms/work_packages/date_picker/form.rb index 174763ee43f1..787007ff150d 100644 --- a/app/forms/work_packages/date_picker/form.rb +++ b/app/forms/work_packages/date_picker/form.rb @@ -64,7 +64,7 @@ def initialize(work_package:, query_form.group(layout: :horizontal) do |group| text_field(group, name: :start_date, label: I18n.t("attributes.start_date")) text_field(group, name: :due_date, label: I18n.t("attributes.due_date")) - text_field(group, name: :duration, label: I18n.t("activerecord.attributes.work_package.duration")) + text_field(group, name: :duration, label: I18n.t("activerecord.attributes.work_package.duration"), disabled: false) hidden_touched_field(group, name: :start_date) hidden_touched_field(group, name: :due_date) @@ -83,13 +83,11 @@ def focused_field_by_selection(field) field end - def text_field(group, - name:, - label:) + def text_field(group, name:, label:, disabled: @disabled) text_field_options = default_field_options(name).merge( name:, value: field_value(name), - disabled: @disabled, + disabled:, label:, validation_message: validation_message(name) ) @@ -97,22 +95,6 @@ def text_field(group, group.text_field(**text_field_options) end - def readonly_text_field(group, - name:, - label:, - placeholder: true) - text_field_options = default_field_options(name).merge( - name:, - value: field_value(name), - label:, - readonly: true, - classes: "input--readonly", - placeholder: ("-" if placeholder) - ) - - group.text_field(**text_field_options) - end - def hidden_touched_field(group, name:) group.hidden(name: :"#{name}_touched", value: touched(name), From 94abe103606b52e3f776001693143750ba3aa4cd Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Thu, 19 Dec 2024 09:40:39 +0100 Subject: [PATCH 23/92] Avoid code repetition --- .../dialog_content_component.html.erb | 62 +++++-------------- .../date_picker/dialog_content_component.rb | 19 ++++++ config/locales/en.yml | 6 ++ 3 files changed, 41 insertions(+), 46 deletions(-) diff --git a/app/components/work_packages/date_picker/dialog_content_component.html.erb b/app/components/work_packages/date_picker/dialog_content_component.html.erb index 1dbb88764764..5d86879e8973 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.html.erb +++ b/app/components/work_packages/date_picker/dialog_content_component.html.erb @@ -8,9 +8,9 @@ end collection.with_component(Primer::Alpha::Dialog::Body.new(classes: "Overlay-body_autocomplete_height")) do - render(Primer::Alpha::UnderlinePanels.new(label: "Test navigation")) do |component| - component.with_tab(selected: true, id: "todo_1") do |tab| - tab.with_text { "Dates" } + render(Primer::Alpha::UnderlinePanels.new(label: I18n.t("work_packages.datepicker_modal.tabs.aria_label"))) do |component| + component.with_tab(selected: true, id: "wp-datepicker-dialog--content-tab--dates") do |tab| + tab.with_text { I18n.t("work_packages.datepicker_modal.tabs.dates") } tab.with_panel do primer_form_with( model: work_package, @@ -98,49 +98,19 @@ end end - component.with_tab(id: "todo_2") do |tab| - tab.with_text { "Predecessors" } - tab.with_counter(count: follow_relations.count) - tab.with_panel do - render(border_box_container(padding: :condensed)) do |component| - follow_relations.each do |relation| - component.with_row(scheme: :default) do - render(WorkPackageRelationsTab::RelationComponent.new(work_package:, - relation:, - editable: false)) - end - end - end - end - end - - component.with_tab(id: "todo_3") do |tab| - tab.with_text { "Successors" } - tab.with_counter(count: precedes_relations.count) - tab.with_panel do - render(border_box_container(padding: :condensed)) do |component| - precedes_relations.each do |relation| - component.with_row(scheme: :default) do - render(WorkPackageRelationsTab::RelationComponent.new(work_package:, - relation:, - editable: false)) - end - end - end - end - end - - component.with_tab(id: "todo_3") do |tab| - tab.with_text { "Children" } - tab.with_counter(count: children.count) - tab.with_panel do - render(border_box_container(padding: :condensed)) do |component| - children.each do |child| - component.with_row(scheme: :default) do - render(WorkPackageRelationsTab::RelationComponent.new(work_package:, - relation: nil, - child:, - editable: false)) + additional_tabs.each do |tab| + component.with_tab(id: "wp-datepicker-dialog--content-tab--#{tab[:key]}") do |tab_content| + tab_content.with_text { I18n.t("work_packages.datepicker_modal.tabs.#{tab[:key]}") } + tab_content.with_counter(count: tab[:relations].count) + tab_content.with_panel do + render(border_box_container(padding: :condensed)) do |box| + tab[:relations].each do |relation| + box.with_row(scheme: :default) do + render(WorkPackageRelationsTab::RelationComponent.new(work_package:, + relation: (relation unless tab[:is_child_relation?]), + child: (relation if tab[:is_child_relation?]), + editable: false)) + end end end end diff --git a/app/components/work_packages/date_picker/dialog_content_component.rb b/app/components/work_packages/date_picker/dialog_content_component.rb index fe83b97af4fc..e6d6672dc241 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.rb +++ b/app/components/work_packages/date_picker/dialog_content_component.rb @@ -27,6 +27,7 @@ #++ # frozen_string_literal: true + module WorkPackages module DatePicker class DialogContentComponent < ApplicationComponent @@ -64,6 +65,24 @@ def disabled? !schedule_manually end + def additional_tabs + [ + { + key: "predecessors", + relations: follow_relations + }, + { + key: "successors", + relations: precedes_relations + }, + { + key: "children", + relations: children, + is_child_relation?: true + } + ] + end + def show_banner? true # TODO end diff --git a/config/locales/en.yml b/config/locales/en.yml index a8a3f7b95af7..87c4303751a9 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -711,6 +711,12 @@ en: automatic: "Automatic" manual: "Manual" show_relations: "Show relations" + tabs: + aria_label: "Datepicker tabs" + children: "Children" + dates: "Dates" + predecessors: "Predecessors" + successors: "Successors" x_descendants: one: "One descendant work package" From 015d0bad89f14481ac850cb9185b752143badf0f Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Thu, 19 Dec 2024 10:40:12 +0100 Subject: [PATCH 24/92] Extract doubled code into a method --- .../wp-modal-date-picker.component.ts | 38 ++++++++----------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts b/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts index b6a8a2f29442..5c4e17b587aa 100644 --- a/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts +++ b/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts @@ -144,34 +144,13 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements this.ensureHoveredSelection(instance.calendarContainer); }, onChange: (dates:Date[], _datestr, instance) => { - // Todo: Make code better if (this.fieldName === 'due_date') { this.dueDate = dates[0]; - - if (this.dueDateFieldId) { - const dueDateField = document.getElementById(this.dueDateFieldId) as HTMLInputElement; - dueDateField.value = this.timezoneService.formattedISODate(this.dueDate); - dueDateField.dispatchEvent(new Event('input')); - } - - // Toggle the active field - if (this.startDateFieldId) { - document.getElementById(this.startDateFieldId)?.focus(); - } + this.setDateFieldAndFocus(this.dueDate, this.dueDateFieldId, this.startDateFieldId); this.fieldName = 'start_date'; } else { this.startDate = dates[0]; - - if (this.startDateFieldId) { - const dueDateField = document.getElementById(this.startDateFieldId) as HTMLInputElement; - dueDateField.value = this.timezoneService.formattedISODate(this.startDate); - dueDateField.dispatchEvent(new Event('input')); - } - - // Toggle the active field - if (this.dueDateFieldId) { - document.getElementById(this.dueDateFieldId)?.focus(); - } + this.setDateFieldAndFocus(this.startDate, this.startDateFieldId, this.dueDateFieldId); this.fieldName = 'due_date'; } @@ -218,4 +197,17 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements ) .subscribe(() => calendarContainer.classList.add('flatpickr-container-suppress-hover')); } + + private setDateFieldAndFocus(date:Date, fieldId:string | null, nextFieldId:string | null):void { + if (fieldId) { + const field = document.getElementById(fieldId) as HTMLInputElement; + field.value = this.timezoneService.formattedISODate(date); + field.dispatchEvent(new Event('input')); + } + + // Toggle focus to the next field + if (nextFieldId) { + document.getElementById(nextFieldId)?.focus(); + } + } } From 5f1a5b9d40cae6cd23878fb142cff6f8575110a1 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Thu, 19 Dec 2024 11:00:03 +0100 Subject: [PATCH 25/92] Please rubocop and eslint --- app/contracts/work_packages/base_contract.rb | 14 ++++++++++++-- .../work_packages/date_picker_controller.rb | 12 ++++++------ .../wp-modal-date-picker.component.ts | 12 ++++++++---- .../combined-date-edit-field.component.ts | 17 +---------------- .../days-duration-edit-field.component.ts | 19 ++----------------- .../modal-with-turbo-content.directive.ts | 8 ++------ .../dialog/preview.controller.ts | 9 ++++++--- 7 files changed, 37 insertions(+), 54 deletions(-) diff --git a/app/contracts/work_packages/base_contract.rb b/app/contracts/work_packages/base_contract.rb index df4b79bfe077..7acd4b431d40 100644 --- a/app/contracts/work_packages/base_contract.rb +++ b/app/contracts/work_packages/base_contract.rb @@ -483,12 +483,22 @@ def validate_people_visible(attribute, id_attribute, list) end def validate_duration_integer - if (!model.duration_before_type_cast.is_a?(String) && model.duration_before_type_cast != model.duration) || - (model.duration_before_type_cast.is_a?(String) && model.duration_before_type_cast.to_i.to_s != model.duration_before_type_cast) + unless valid_duration?(model.duration_before_type_cast, model.duration) errors.add :duration, :not_an_integer end end + def valid_duration?(value, duration) + # the values don't match (e.g because a float was passed) + return false if !value.is_a?(String) && value != duration + + # A string is passed, put the transformed value does not match + return false if value.is_a?(String) && value.to_i.to_s != value + + # duration is valid + true + end + def validate_duration_matches_dates return unless calculated_duration && model.duration diff --git a/app/controllers/work_packages/date_picker_controller.rb b/app/controllers/work_packages/date_picker_controller.rb index 99d31a4cecc8..bcc6f07fdc15 100644 --- a/app/controllers/work_packages/date_picker_controller.rb +++ b/app/controllers/work_packages/date_picker_controller.rb @@ -130,13 +130,13 @@ def touched_field_map end def schedule_manually - if params[:schedule_manually].present? - params[:schedule_manually] - elsif params[:work_package].present? && params[:work_package][:schedule_manually].present? - params[:work_package][:schedule_manually] - else + find_if_present(params[:schedule_manually]) || + find_if_present(params.dig(:work_package, :schedule_manually)) || work_package.schedule_manually - end + end + + def find_if_present(value) + value.presence end def work_package_datepicker_params diff --git a/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts b/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts index 5c4e17b587aa..b3f23b08ee15 100644 --- a/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts +++ b/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts @@ -101,6 +101,7 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements document.addEventListener('date-picker:input-changed', this.changeListener.bind(this)); } + // eslint-disable-next-line @angular-eslint/use-lifecycle-interface ngOnDestroy():void { super.ngOnDestroy(); @@ -108,15 +109,17 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements } changeListener(event:CustomEvent) { - switch (event.detail.field) { + const details = (event.detail as { field:string, value:string }); + + switch (details.field) { case 'work_package[start_date]': - this.startDate = event.detail.value; + this.startDate = new Date(details.value); break; case 'work_package[due_date]': - this.dueDate = event.detail.value; + this.dueDate = new Date(details.value); break; case 'work_package[ignore_non_working_days]': - this.ignoreNonWorkingDays = event.detail.value !== 'true'; + this.ignoreNonWorkingDays = details.value !== 'true'; break; default: // Case fallthrough for eslint @@ -166,6 +169,7 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements ); }, }, + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument this.flatpickrTarget.nativeElement, ); } diff --git a/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.ts index 1060a96c101a..a2e7e0ba82fd 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.ts @@ -27,26 +27,11 @@ //++ import { - 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 { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; -import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; -import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset'; -import { HalResource } from 'core-app/features/hal/resources/hal-resource'; -import { IFieldSchema } from 'core-app/shared/components/fields/field.base'; -import { EditFieldHandler } from 'core-app/shared/components/fields/edit/editing-portal/edit-field-handler'; -import { - OpEditingPortalChangesetToken, - OpEditingPortalHandlerToken, - OpEditingPortalSchemaToken, -} from 'core-app/shared/components/fields/edit/edit-field.component'; @Component({ templateUrl: './combined-date-edit-field.component.html', @@ -90,7 +75,7 @@ export class CombinedDateEditFieldComponent extends DatePickerEditFieldComponent } public save():void { - this.handler.handleUserSubmit(); + void this.handler.handleUserSubmit(); } public cancel():void { 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 d3ece8129151..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 @@ -28,32 +28,17 @@ 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 implements OnInit{ +export class DaysDurationEditFieldComponent extends DatePickerEditFieldComponent implements OnInit { opened = false; public get formattedValue():number { @@ -73,7 +58,7 @@ export class DaysDurationEditFieldComponent extends DatePickerEditFieldComponent } onModalClosed() { - this.handler.handleUserSubmit(); + void this.handler.handleUserSubmit(); this.opened = false; } 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 index d1b051bc7e7d..24a9e8acb68b 100644 --- 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 @@ -70,9 +70,7 @@ export class ModalWithTurboContentDirective implements AfterViewInit, OnDestroy } ngAfterViewInit() { - this - .elementRef - .nativeElement + (this.elementRef.nativeElement as HTMLElement) .addEventListener('turbo:submit-end', this.contextBasedListenerBound); document @@ -80,9 +78,7 @@ export class ModalWithTurboContentDirective implements AfterViewInit, OnDestroy } ngOnDestroy() { - this - .elementRef - .nativeElement + (this.elementRef.nativeElement as HTMLElement) .removeEventListener('turbo:submit-end', this.contextBasedListenerBound); document 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 index f51d4964e7c8..93c6368dc314 100644 --- a/frontend/src/stimulus/controllers/dynamic/work-packages/dialog/preview.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/work-packages/dialog/preview.controller.ts @@ -29,7 +29,10 @@ */ import { Controller } from '@hotwired/stimulus'; -import { debounce, DebouncedFunc } from 'lodash'; +import { + debounce, + DebouncedFunc, +} from 'lodash'; import Idiomorph from 'idiomorph/dist/idiomorph.cjs'; interface TurboBeforeFrameRenderEventDetail { @@ -82,12 +85,12 @@ export abstract class DialogPreviewController extends Controller { Idiomorph.morph(currentElement, newElement, { ignoreActiveValue: true, callbacks: { - beforeNodeMorphed: (oldNode:Element, newNode:Element) => { + 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[')) { + if (newNode.tagName === 'INPUT' && (newNode as HTMLInputElement).name && (newNode as HTMLInputElement).name.startsWith('work_package[')) { this.dispatchChangeEvent((newNode as HTMLInputElement)); } }, From 6311fb75f7062f78bf1b81b9a788efad936d5dda Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Fri, 20 Dec 2024 12:50:36 +0100 Subject: [PATCH 26/92] Handle Milestones in new datepicker modal --- .../dialog_content_component.html.erb | 1 + .../work_packages/date_picker_controller.rb | 10 + app/forms/work_packages/date_picker/form.rb | 21 +- .../datepicker/datepicker.module.ts | 3 - .../wp-modal-date-picker.component.ts | 19 +- .../wp-single-date-form.component.html | 78 ----- .../wp-single-date-form.component.sass | 6 - .../wp-single-date-form.component.ts | 304 ------------------ .../combined-date-edit-field.component.html | 10 +- .../combined-date-edit-field.component.ts | 4 - 10 files changed, 41 insertions(+), 415 deletions(-) delete mode 100644 frontend/src/app/shared/components/datepicker/wp-single-date-form/wp-single-date-form.component.html delete mode 100644 frontend/src/app/shared/components/datepicker/wp-single-date-form/wp-single-date-form.component.sass delete mode 100644 frontend/src/app/shared/components/datepicker/wp-single-date-form/wp-single-date-form.component.ts diff --git a/app/components/work_packages/date_picker/dialog_content_component.html.erb b/app/components/work_packages/date_picker/dialog_content_component.html.erb index 5d86879e8973..68b5c74f1c94 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.html.erb +++ b/app/components/work_packages/date_picker/dialog_content_component.html.erb @@ -88,6 +88,7 @@ ignore_non_working_days: work_package.ignore_non_working_days, schedule_manually:, is_schedulable: !disabled?, + is_milestone: work_package.is_milestone?, field_name: focused_field, start_date_field_id: "work_package_start_date", due_date_field_id: "work_package_due_date" diff --git a/app/controllers/work_packages/date_picker_controller.rb b/app/controllers/work_packages/date_picker_controller.rb index bcc6f07fdc15..debb58ec9501 100644 --- a/app/controllers/work_packages/date_picker_controller.rb +++ b/app/controllers/work_packages/date_picker_controller.rb @@ -141,6 +141,8 @@ def find_if_present(value) def work_package_datepicker_params if params[:work_package] + handle_milestone_dates + params.require(:work_package) .slice(*allowed_touched_params) .merge(schedule_manually:) @@ -171,4 +173,12 @@ def set_date_attributes_to_work_package .call(wp_params) end end + + def handle_milestone_dates + if work_package.is_milestone? + # Set the dueDate as the SetAttributesService will otherwise throw an error because the fields do not match + params.require(:work_package)[:due_date] = params.require(:work_package)[:start_date] + params.require(:work_package)[:due_date_touched] = "true" + end + end end diff --git a/app/forms/work_packages/date_picker/form.rb b/app/forms/work_packages/date_picker/form.rb index 787007ff150d..294e634f0054 100644 --- a/app/forms/work_packages/date_picker/form.rb +++ b/app/forms/work_packages/date_picker/form.rb @@ -55,6 +55,7 @@ def initialize(work_package:, super() @work_package = work_package + @is_milestone = work_package.milestone? @focused_field = focused_field_by_selection(focused_field) @touched_field_map = touched_field_map @disabled = disabled @@ -62,13 +63,21 @@ def initialize(work_package:, form do |query_form| query_form.group(layout: :horizontal) do |group| - text_field(group, name: :start_date, label: I18n.t("attributes.start_date")) - text_field(group, name: :due_date, label: I18n.t("attributes.due_date")) - text_field(group, name: :duration, label: I18n.t("activerecord.attributes.work_package.duration"), disabled: false) - hidden_touched_field(group, name: :start_date) - hidden_touched_field(group, name: :due_date) - hidden_touched_field(group, name: :duration) + if @is_milestone + text_field(group, name: :start_date, label: I18n.t("attributes.date")) + + hidden_touched_field(group, name: :start_date) + else + text_field(group, name: :start_date, label: I18n.t("attributes.start_date")) + text_field(group, name: :due_date, label: I18n.t("attributes.due_date")) + text_field(group, name: :duration, label: I18n.t("activerecord.attributes.work_package.duration")) + + hidden_touched_field(group, name: :start_date) + hidden_touched_field(group, name: :due_date) + hidden_touched_field(group, name: :duration) + end + hidden_touched_field(group, name: :ignore_non_working_days) group.fields_for(:initial) do |builder| diff --git a/frontend/src/app/shared/components/datepicker/datepicker.module.ts b/frontend/src/app/shared/components/datepicker/datepicker.module.ts index 5c422abc728d..dc22fd0861e1 100644 --- a/frontend/src/app/shared/components/datepicker/datepicker.module.ts +++ b/frontend/src/app/shared/components/datepicker/datepicker.module.ts @@ -8,7 +8,6 @@ import { CommonModule } from '@angular/common'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { OpModalSingleDatePickerComponent } from './modal-single-date-picker/modal-single-date-picker.component'; -import { OpWpSingleDateFormComponent } from './wp-single-date-form/wp-single-date-form.component'; import { OpBasicDatePickerModule } from './basic-datepicker.module'; import { OpSpotModule } from 'core-app/spot/spot.module'; import { OpenprojectModalModule } from '../modal/modal.module'; @@ -34,14 +33,12 @@ import { OpWpModalDatePickerComponent } from 'core-app/shared/components/datepic declarations: [ OpModalSingleDatePickerComponent, - OpWpSingleDateFormComponent, OpDatePickerSheetComponent, OpWpModalDatePickerComponent, ], exports: [ OpModalSingleDatePickerComponent, - OpWpSingleDateFormComponent, OpBasicDatePickerModule, OpDatePickerSheetComponent, OpWpModalDatePickerComponent, diff --git a/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts b/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts index b3f23b08ee15..13f5db94c867 100644 --- a/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts +++ b/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts @@ -77,6 +77,8 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements @Input() startDateFieldId:string; @Input() dueDateFieldId:string; + @Input() isMilestone:boolean = false; + @ViewChild('flatpickrTarget') flatpickrTarget:ElementRef; private datePickerInstance:DatePicker; @@ -136,9 +138,9 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements this.datePickerInstance = new DatePicker( this.injector, '#flatpickr-input', - [this.startDate || '', this.dueDate || ''], + this.isMilestone ? this.startDate : [this.startDate || '', this.dueDate || ''], { - mode: 'range', + mode: this.isMilestone ? 'single' : 'range', showMonths: this.deviceService.isMobile ? 1 : 2, inline: true, onReady: (_date, _datestr, instance) => { @@ -147,17 +149,24 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements this.ensureHoveredSelection(instance.calendarContainer); }, onChange: (dates:Date[], _datestr, instance) => { - if (this.fieldName === 'due_date') { + if (this.isMilestone) { + this.startDate = dates[0]; + this.setDateFieldAndFocus(this.startDate, this.startDateFieldId, null); + + instance.setDate(this.startDate); + } else if (this.fieldName === 'due_date') { this.dueDate = dates[0]; this.setDateFieldAndFocus(this.dueDate, this.dueDateFieldId, this.startDateFieldId); this.fieldName = 'start_date'; + + instance.setDate([this.startDate, this.dueDate]); } else { this.startDate = dates[0]; this.setDateFieldAndFocus(this.startDate, this.startDateFieldId, this.dueDateFieldId); this.fieldName = 'due_date'; - } - instance.setDate([this.startDate, this.dueDate]); + instance.setDate([this.startDate, this.dueDate]); + } }, // eslint-disable-next-line @typescript-eslint/no-misused-promises onDayCreate: async (dObj:Date[], dStr:string, fp:flatpickr.Instance, dayElem:DayElement) => { diff --git a/frontend/src/app/shared/components/datepicker/wp-single-date-form/wp-single-date-form.component.html b/frontend/src/app/shared/components/datepicker/wp-single-date-form/wp-single-date-form.component.html deleted file mode 100644 index 78876389694c..000000000000 --- a/frontend/src/app/shared/components/datepicker/wp-single-date-form/wp-single-date-form.component.html +++ /dev/null @@ -1,78 +0,0 @@ -
- - -
- - - - - - - - - - -
- -
-
- - -
-
-
diff --git a/frontend/src/app/shared/components/datepicker/wp-single-date-form/wp-single-date-form.component.sass b/frontend/src/app/shared/components/datepicker/wp-single-date-form/wp-single-date-form.component.sass deleted file mode 100644 index 1980fd099963..000000000000 --- a/frontend/src/app/shared/components/datepicker/wp-single-date-form/wp-single-date-form.component.sass +++ /dev/null @@ -1,6 +0,0 @@ -.op-wp-single-date-form - height: 100% - .op-modal-banner - grid-template-columns: auto max-content !important - .spot-icon, .op-modal-banner--subtitle - display: none \ No newline at end of file diff --git a/frontend/src/app/shared/components/datepicker/wp-single-date-form/wp-single-date-form.component.ts b/frontend/src/app/shared/components/datepicker/wp-single-date-form/wp-single-date-form.component.ts deleted file mode 100644 index da53a7a94084..000000000000 --- a/frontend/src/app/shared/components/datepicker/wp-single-date-form/wp-single-date-form.component.ts +++ /dev/null @@ -1,304 +0,0 @@ -//-- copyright -// OpenProject is an open source project management software. -// Copyright (C) the OpenProject GmbH -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License version 3. -// -// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -// Copyright (C) 2006-2013 Jean-Philippe Lang -// Copyright (C) 2010-2013 the ChiliProject Team -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License -// as published by the Free Software Foundation; either version 2 -// of the License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -// -// See COPYRIGHT and LICENSE files for more details. -//++ - -import { - AfterViewInit, - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - ElementRef, - EventEmitter, - OnInit, - Injector, - Input, - Output, - ViewChild, - ViewEncapsulation, - HostBinding, -} from '@angular/core'; -import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { DatePicker } from 'core-app/shared/components/datepicker/datepicker'; -import { HalResourceEditingService } from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service'; -import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset'; -import { ConfigurationService } from 'core-app/core/config/configuration.service'; -import { TimezoneService } from 'core-app/core/datetime/timezone.service'; -import { DayElement } from 'flatpickr/dist/types/instance'; -import flatpickr from 'flatpickr'; -import { debounce } from 'rxjs/operators'; -import { - Subject, - timer, -} from 'rxjs'; -import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; -import { FormResource } from 'core-app/features/hal/resources/form-resource'; -import { DateModalRelationsService } from 'core-app/shared/components/datepicker/services/date-modal-relations.service'; -import { - mappedDate, - onDayCreate, - parseDate, - setDates, - validDate, -} from 'core-app/shared/components/datepicker/helpers/date-modal.helpers'; -import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; -import { WorkPackageChangeset } from 'core-app/features/work-packages/components/wp-edit/work-package-changeset'; -import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; -import { DateModalSchedulingService } from '../services/date-modal-scheduling.service'; -import * as moment from 'moment-timezone'; - - -@Component({ - selector: 'op-wp-single-date-form', - templateUrl: './wp-single-date-form.component.html', - styleUrls: [ - './wp-single-date-form.component.sass', - '../styles/datepicker.modal.sass', - ], - changeDetection: ChangeDetectionStrategy.OnPush, - encapsulation: ViewEncapsulation.None, - providers: [ - DateModalRelationsService, - DateModalSchedulingService, - ], -}) -export class OpWpSingleDateFormComponent extends UntilDestroyedMixin implements AfterViewInit, OnInit { - @HostBinding('class.op-wp-single-date-form') className = true; - - @Input('value') value = ''; - - @Input() changeset:ResourceChangeset; - - @Output() cancel = new EventEmitter(); - - @Output() save = new EventEmitter(); - - @ViewChild('flatpickrTarget') flatpickrTarget:ElementRef; - - @ViewChild('modalContainer') modalContainer:ElementRef; - - text = { - save: this.I18n.t('js.button_save'), - cancel: this.I18n.t('js.button_cancel'), - date: this.I18n.t('js.work_packages.properties.date'), - placeholder: this.I18n.t('js.placeholders.default'), - today: this.I18n.t('js.label_today'), - }; - - scheduleManually = false; - - ignoreNonWorkingDays = false; - - htmlId = ''; - - date:string|null = null; - - dateChangedManually$ = new Subject(); - - private debounceDelay = 0; // will change after initial render - - private datePickerInstance:DatePicker; - - constructor( - readonly configurationService:ConfigurationService, - readonly apiV3Service:ApiV3Service, - readonly cdRef:ChangeDetectorRef, - readonly injector:Injector, - readonly I18n:I18nService, - readonly timezoneService:TimezoneService, - readonly halEditing:HalResourceEditingService, - readonly dateModalScheduling:DateModalSchedulingService, - readonly dateModalRelations:DateModalRelationsService, - ) { - super(); - } - - ngOnInit():void { - this.dateModalRelations.setChangeset(this.changeset as WorkPackageChangeset); - this.dateModalScheduling.setChangeset(this.changeset as WorkPackageChangeset); - this.scheduleManually = !!this.changeset.value('scheduleManually'); - this.ignoreNonWorkingDays = !!this.changeset.value('ignoreNonWorkingDays'); - - if (!moment(this.value).isValid()) { - this.value = ''; - this.date = ''; - return; - } - this.date = this.timezoneService.formattedISODate(this.value); - } - - ngAfterViewInit():void { - if (isNewResource(this.changeset.pristineResource)) { - this.initializeDatepicker(null); - } else { - this - .dateModalRelations - .getMinimalDateFromPreceeding() - .subscribe((date) => { - this.initializeDatepicker(date); - }); - } - - this - .dateChangedManually$ - .pipe( - // Avoid that the manual changes are moved to the datepicker too early. - // The debounce is chosen quite large on purpose to catch the following case: - // 1. date is for example 2022-07-15. The user wants to set the day value to the 19th. - // 2. So he/she starts entering the finish date 2022-07-1 . - // 3. This is already a valid date. Since it is before the date,the date would be changed automatically to the first without the debounce. - // 4. The debounce gives the user enough time to type the last number "9" before the changes are converted to the datepicker and the start date would be affected. - // - // Debounce delay is 0 for initial display, and then set to 800 - debounce(() => timer(this.debounceDelay)), - ) - .subscribe(() => { - // set debounce delay to its real value - this.debounceDelay = 800; - - // Always update the whole form to ensure that no values are lost/inconsistent - this.updateDate(this.date); - }); - } - - changeSchedulingMode():void { - this.datePickerInstance?.datepickerInstance.redraw(); - this.cdRef.detectChanges(); - } - - /** - * Returns whether the user can alter the dates of the work package. - */ - get isSchedulable():boolean { - return this.scheduleManually || !this.dateModalRelations.isParent; - } - - isDayDisabled(dayElement:DayElement, minimalDate?:Date|null):boolean { - return !this.isSchedulable || (!this.scheduleManually && !!minimalDate && dayElement.dateObj <= minimalDate); - } - - changeNonWorkingDays():void { - this.datePickerInstance?.datepickerInstance.redraw(); - this.cdRef.detectChanges(); - } - - doSave($event:Event):void { - $event.preventDefault(); - // Apply the changed scheduling mode if any - this.changeset.setValue('scheduleManually', this.scheduleManually); - - // Apply include NWD - this.changeset.setValue('ignoreNonWorkingDays', this.ignoreNonWorkingDays); - - // Apply the dates if they could be changed - if (this.isSchedulable) { - this.changeset.setValue('date', mappedDate(this.date)); - } - - this.save.emit(); - } - - doCancel():void { - this.cancel.emit(); - } - - updateDate(val:string|null):void { - // Expected minimal format YYYY-M-D => 8 characters OR empty - if (val !== null && (val.length >= 8 || val.length === 0)) { - if (validDate(val) && this.datePickerInstance) { - const dateValue = parseDate(val) || undefined; - this.enforceManualChangesToDatepicker(dateValue); - } - } - } - - setToday():void { - const today = parseDate(new Date()) as Date; - this.date = this.timezoneService.formattedISODate(today); - this.enforceManualChangesToDatepicker(today); - } - - private initializeDatepicker(minimalDate?:Date|null) { - this.datePickerInstance?.destroy(); - this.datePickerInstance = new DatePicker( - this.injector, - '#flatpickr-input', - this.date || '', - { - mode: 'single', - showMonths: 1, - inline: true, - onReady: (_date:Date[], _datestr:string, instance:flatpickr.Instance) => { - instance.calendarContainer.classList.add('op-datepicker-modal--flatpickr-instance'); - }, - onChange: (dates:Date[]) => { - if (dates.length > 0) { - this.date = this.timezoneService.formattedISODate(dates[0]); - this.enforceManualChangesToDatepicker(dates[0]); - } - - this.cdRef.detectChanges(); - }, - // eslint-disable-next-line @typescript-eslint/no-misused-promises - onDayCreate: async (dObj:Date[], dStr:string, fp:flatpickr.Instance, dayElem:DayElement) => { - onDayCreate( - dayElem, - this.ignoreNonWorkingDays, - await this.datePickerInstance?.isNonWorkingDay(dayElem.dateObj), - this.isDayDisabled(dayElem, minimalDate), - ); - }, - }, - this.flatpickrTarget.nativeElement, - ); - } - - private enforceManualChangesToDatepicker(enforceDate?:Date) { - const date = parseDate(this.date || ''); - setDates(date, this.datePickerInstance, enforceDate); - - if (date) { - this.date = this.timezoneService.formattedISODate(date); - } - } - - /** - * Update the datepicker dates and properties from a form response - * that includes derived/calculated values. - * - * @param form - * @private - */ - private updateDatesFromForm(form:FormResource):void { - const payload = form.payload as { date:string, ignoreNonWorkingDays:boolean }; - - this.date = payload.date; - this.ignoreNonWorkingDays = payload.ignoreNonWorkingDays; - - const parsedDate = parseDate(payload.date) as Date; - this.enforceManualChangesToDatepicker(parsedDate); - } -} diff --git a/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.html b/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.html index 6b97748c11f0..aa8eea2efbd2 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.html +++ b/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.html @@ -13,15 +13,7 @@ /> - - - Date: Fri, 20 Dec 2024 13:32:08 +0100 Subject: [PATCH 27/92] If the WP is automatically scheduled but there is no predecessor, we show a blankslate --- .../dialog_content_component.html.erb | 54 +++++++++++-------- .../date_picker/dialog_content_component.rb | 10 ++-- config/locales/en.yml | 3 ++ 3 files changed, 40 insertions(+), 27 deletions(-) diff --git a/app/components/work_packages/date_picker/dialog_content_component.html.erb b/app/components/work_packages/date_picker/dialog_content_component.html.erb index 68b5c74f1c94..de220c2312df 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.html.erb +++ b/app/components/work_packages/date_picker/dialog_content_component.html.erb @@ -3,7 +3,7 @@ component_wrapper(data: { "application-target": "dynamic", controller: "work-packages--date-picker--preview" }) do component_collection do |collection| - if show_banner? + if schedulable? collection.with_component(WorkPackages::DatePicker::BannerComponent.new(work_package:, manually_scheduled: schedule_manually)) end @@ -72,27 +72,37 @@ end end - body.with_row(mb: 3) do - render(WorkPackages::DatePicker::Form.new(f, - work_package:, - focused_field:, - touched_field_map:, - disabled: disabled?)) - end + if schedulable? + body.with_row(mb: 3) do + render(WorkPackages::DatePicker::Form.new(f, + work_package:, + focused_field:, + touched_field_map:, + disabled: disabled?)) + end - body.with_row(mb: 3) do - helpers.angular_component_tag "opce-wp-modal-date-picker", - inputs: { - start_date: work_package.start_date, - due_date: work_package.due_date, - ignore_non_working_days: work_package.ignore_non_working_days, - schedule_manually:, - is_schedulable: !disabled?, - is_milestone: work_package.is_milestone?, - field_name: focused_field, - start_date_field_id: "work_package_start_date", - due_date_field_id: "work_package_due_date" - } + body.with_row(mb: 3) do + helpers.angular_component_tag "opce-wp-modal-date-picker", + inputs: { + start_date: work_package.start_date, + due_date: work_package.due_date, + ignore_non_working_days: work_package.ignore_non_working_days, + schedule_manually:, + is_schedulable: !disabled?, + is_milestone: work_package.is_milestone?, + field_name: focused_field, + start_date_field_id: "work_package_start_date", + due_date_field_id: "work_package_due_date" + } + end + else + body.with_row(mb: 3) do + render(Primer::Beta::Blankslate.new(border: true)) do |component| + component.with_visual_icon(icon: :book, size: :medium) + component.with_heading(tag: :h2) { I18n.t("work_packages.datepicker_modal.blankslate.title") } + component.with_description { I18n.t("work_packages.datepicker_modal.blankslate.description") } + end + end end end end @@ -105,7 +115,7 @@ tab_content.with_counter(count: tab[:relations].count) tab_content.with_panel do render(border_box_container(padding: :condensed)) do |box| - tab[:relations].each do |relation| + tab[:relations].visible.each do |relation| box.with_row(scheme: :default) do render(WorkPackageRelationsTab::RelationComponent.new(work_package:, relation: (relation unless tab[:is_child_relation?]), diff --git a/app/components/work_packages/date_picker/dialog_content_component.rb b/app/components/work_packages/date_picker/dialog_content_component.rb index e6d6672dc241..fb5ce2e85ec9 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.rb +++ b/app/components/work_packages/date_picker/dialog_content_component.rb @@ -50,15 +50,15 @@ def initialize(work_package:, schedule_manually: true, focused_field: :start_dat private def precedes_relations - work_package.precedes_relations.visible + @precedes_relations ||= work_package.precedes_relations end def follow_relations - work_package.follows_relations.visible + @follow_relations ||= work_package.follows_relations end def children - work_package.children.visible + @children ||= work_package.children end def disabled? @@ -83,8 +83,8 @@ def additional_tabs ] end - def show_banner? - true # TODO + def schedulable? + @schedule_manually || precedes_relations.any? end end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 87c4303751a9..f3e55436f380 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -704,6 +704,9 @@ en: automatic_with_children: "The dates are determined by child work packages." automatic_with_predecessor: "The start date is set by a predecessor." manually_scheduled: "Manually scheduled. Dates not affected by relations." + blankslate: + title: "No predecessors" + description: "To enable automatic scheduling, this work package needs to have at least one predecessor. It will then automatically be scheduled to start after the last predecessor." ignore_non_working_days: title: "Working days only" mode: From 67437d066f21cb3ed03073c3b80410496dfd6134 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Fri, 10 Jan 2025 15:38:42 +0100 Subject: [PATCH 28/92] Fine tune text and spaces --- .../work_packages/date_picker/banner_component.html.erb | 1 - .../work_packages/date_picker/dialog_content_component.html.erb | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/components/work_packages/date_picker/banner_component.html.erb b/app/components/work_packages/date_picker/banner_component.html.erb index cc1ad5083ec5..24583d5eeeaa 100644 --- a/app/components/work_packages/date_picker/banner_component.html.erb +++ b/app/components/work_packages/date_picker/banner_component.html.erb @@ -3,7 +3,6 @@ full: true, icon: :info, description:, - mb: 3, classes: "rounded-top-2")) do |banner| banner.with_action_button(tag: :a, href: link, target: "_blank") { I18n.t("work_packages.datepicker_modal.show_relations") } title diff --git a/app/components/work_packages/date_picker/dialog_content_component.html.erb b/app/components/work_packages/date_picker/dialog_content_component.html.erb index de220c2312df..03001a1c1b64 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.html.erb +++ b/app/components/work_packages/date_picker/dialog_content_component.html.erb @@ -137,7 +137,7 @@ end footer.with_component(Primer::ButtonComponent.new(scheme: :primary, type: :submit, form: DIALOG_FORM_ID)) do - I18n.t("button_submit") + I18n.t("button_save") end end end From 308cff88c8e21cb8f1cf2619a88efc990d1b86bf Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 13 Jan 2025 14:41:57 +0100 Subject: [PATCH 29/92] Datepicker for WorkPackage Create case --- .../dialog_content_component.html.erb | 4 +- .../date_picker/dialog_content_component.rb | 17 +++++- .../work_packages/date_picker_controller.rb | 54 ++++++++++++++++++- app/forms/work_packages/date_picker/form.rb | 6 +-- config/routes.rb | 6 +-- .../core/path-helper/path-helper.service.ts | 4 ++ .../combined-date-edit-field.component.ts | 6 ++- .../date-picker-edit-field.component.ts | 43 ++++++++++++++- .../date-picker/preview.controller.ts | 6 +-- 9 files changed, 128 insertions(+), 18 deletions(-) diff --git a/app/components/work_packages/date_picker/dialog_content_component.html.erb b/app/components/work_packages/date_picker/dialog_content_component.html.erb index 03001a1c1b64..9cf28a83681b 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.html.erb +++ b/app/components/work_packages/date_picker/dialog_content_component.html.erb @@ -14,8 +14,7 @@ tab.with_panel do primer_form_with( model: work_package, - url: work_package_datepicker_dialog_content_path, - method: :put, + url: submit_path, id: DIALOG_FORM_ID, data: { "work-packages--date-picker--preview-target": "form" }, html: { autocomplete: "off" }, @@ -36,6 +35,7 @@ control.with_item(tag: :a, href: work_package_datepicker_dialog_content_path(params.merge(schedule_manually: false).permit!), data: { turbo_stream: true }, + disabled: work_package.new_record?, label: I18n.t("work_packages.datepicker_modal.mode.automatic"), title: I18n.t("work_packages.datepicker_modal.mode.automatic"), selected: !schedule_manually) diff --git a/app/components/work_packages/date_picker/dialog_content_component.rb b/app/components/work_packages/date_picker/dialog_content_component.rb index fb5ce2e85ec9..f593486d9aa3 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.rb +++ b/app/components/work_packages/date_picker/dialog_content_component.rb @@ -43,12 +43,23 @@ def initialize(work_package:, schedule_manually: true, focused_field: :start_dat @work_package = work_package @schedule_manually = ActiveModel::Type::Boolean.new.cast(schedule_manually) - @focused_field = focused_field + @focused_field = parse_focused_field(focused_field) @touched_field_map = touched_field_map end private + def submit_path + if work_package.new_record? + url_for(controller: "work_packages/date_picker", + action: "create") + else + url_for(controller: "work_packages/date_picker", + action: "update", + work_package_id: work_package.id) + end + end + def precedes_relations @precedes_relations ||= work_package.precedes_relations end @@ -86,6 +97,10 @@ def additional_tabs def schedulable? @schedule_manually || precedes_relations.any? end + + def parse_focused_field(focused_field) + %i[start_date due_date duration].include?(focused_field) ? focused_field : :start_date + end end end end diff --git a/app/controllers/work_packages/date_picker_controller.rb b/app/controllers/work_packages/date_picker_controller.rb index debb58ec9501..a4ae482de196 100644 --- a/app/controllers/work_packages/date_picker_controller.rb +++ b/app/controllers/work_packages/date_picker_controller.rb @@ -37,8 +37,8 @@ class WorkPackages::DatePickerController < ApplicationController layout false - before_action :find_work_package - authorization_checked! :show, :update, :edit + before_action :find_work_package, except: %i[new create] + authorization_checked! :show, :update, :edit, :new, :create attr_accessor :work_package @@ -64,12 +64,54 @@ def show end end + def new + make_fake_initial_work_package + set_date_attributes_to_work_package + + render datepicker_modal_component, status: :ok + end + def edit set_date_attributes_to_work_package render datepicker_modal_component end + # rubocop:disable Metrics/AbcSize + def create + make_fake_initial_work_package + service_call = set_date_attributes_to_work_package + + if service_call.errors + .map(&:attribute) + .intersect?(ERROR_PRONE_ATTRIBUTES) + respond_to do |format| + format.turbo_stream do + # Bundle 422 status code into stream response so + # Angular has context as to the success or failure of + # the request in order to fetch the new set of Work Package + # attributes in the ancestry solely on success. + render turbo_stream: [ + turbo_stream.morph("wp-datepicker-dialog--content", progress_modal_component) + ], status: :unprocessable_entity + end + end + else + render json: { + startDate: @work_package.start_date, + dueDate: @work_package.due_date, + duration: @work_package.duration, + scheduleManually: @work_package.schedule_manually, + includeNonWorkingDays: if @work_package.ignore_non_working_days.nil? + false + else + @work_package.ignore_non_working_days + end + } + end + end + # rubocop:enable Metrics/AbcSize + def update service_call = WorkPackages::UpdateService .new(user: current_user, @@ -162,6 +204,14 @@ def touched?(field) touched_field_map[:"#{field}_touched"] end + def make_fake_initial_work_package + initial_params = params["work_package"]["initial"] + .slice(*%w[start_date due_date duration ignore_non_working_days]) + .permit! + @work_package = WorkPackage.new(initial_params) + @work_package.clear_changes_information + end + def set_date_attributes_to_work_package wp_params = work_package_datepicker_params diff --git a/app/forms/work_packages/date_picker/form.rb b/app/forms/work_packages/date_picker/form.rb index 294e634f0054..4ac60fb81040 100644 --- a/app/forms/work_packages/date_picker/form.rb +++ b/app/forms/work_packages/date_picker/form.rb @@ -56,7 +56,7 @@ def initialize(work_package:, @work_package = work_package @is_milestone = work_package.milestone? - @focused_field = focused_field_by_selection(focused_field) + @focused_field = focused_field @touched_field_map = touched_field_map @disabled = disabled end @@ -88,10 +88,6 @@ def initialize(work_package:, private - def focused_field_by_selection(field) - field - end - def text_field(group, name:, label:, disabled: @disabled) text_field_options = default_field_options(name).merge( name:, diff --git a/config/routes.rb b/config/routes.rb index a8822517937a..2d1eaa853301 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -643,16 +643,16 @@ controller: "work_packages/progress", as: :work_package_progress end + resource :datepicker_dialog_content, only: %i[show new edit update], controller: "work_packages/date_picker", on: :member, as: "datepicker_dialog_content" collection do - resource :progress, + resource :datepicker_dialog_content, only: :create, - controller: "work_packages/date_picker", - as: :work_package_progress + controller: "work_packages/date_picker" end resources :relations_tab, only: %i[index], controller: "work_package_relations_tab" diff --git a/frontend/src/app/core/path-helper/path-helper.service.ts b/frontend/src/app/core/path-helper/path-helper.service.ts index 8263a383c591..ee848b3fe65f 100644 --- a/frontend/src/app/core/path-helper/path-helper.service.ts +++ b/frontend/src/app/core/path-helper/path-helper.service.ts @@ -309,6 +309,10 @@ export class PathHelperService { } public workPackageDatepickerDialogContentPath(workPackageId:string|number):string { + if (workPackageId === 'new') { + return `${this.workPackagePath(workPackageId)}/datepicker_dialog_content/new`; + } + return `${this.workPackagePath(workPackageId)}/datepicker_dialog_content`; } diff --git a/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.ts index 37ac8316003d..b97bf66c0490 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.ts @@ -58,6 +58,7 @@ export class CombinedDateEditFieldComponent extends DatePickerEditFieldComponent } public showDatePickerModal():void { + this.updateFrameSrc(); this.opened = true; } @@ -75,7 +76,10 @@ export class CombinedDateEditFieldComponent extends DatePickerEditFieldComponent } public cancel():void { - this.handler.reset(); + if (!this.handler.inEditMode) { + this.handler.reset(); + } + this.onModalClosed(); } // Overwrite super in order to set the initial dates. diff --git a/frontend/src/app/shared/components/fields/edit/field-types/date-picker-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/date-picker-edit-field.component.ts index 0b2a2e833abd..a3c1ca170874 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/date-picker-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/date-picker-edit-field.component.ts @@ -94,7 +94,7 @@ export abstract class DatePickerEditFieldComponent extends EditFieldComponent im 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.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 @@ -112,4 +112,45 @@ export abstract class DatePickerEditFieldComponent extends EditFieldComponent im } 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/stimulus/controllers/dynamic/work-packages/date-picker/preview.controller.ts b/frontend/src/stimulus/controllers/dynamic/work-packages/date-picker/preview.controller.ts index 85091887cb9c..eeb902002b84 100644 --- 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 @@ -40,16 +40,16 @@ export default class PreviewController extends DialogPreviewController { ensureValidPathname(formAction:string):string { const wpPath = new URL(formAction); - if (wpPath.pathname.endsWith('/work_packages/date_picker')) { + 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/date_picker', '/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/date_picker') ? 'new' : 'edit'; + return wpPath.endsWith('/work_packages/new/datepicker_dialog_content') ? 'new' : 'edit'; } dispatchChangeEvent(field:HTMLInputElement) { From d5d751c4af01faccf72277df8163edd6a3ef010e Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 13 Jan 2025 14:44:02 +0100 Subject: [PATCH 30/92] Disbale saving when scheduling is not possible --- .../date_picker/dialog_content_component.html.erb | 5 ++++- .../work_packages/date_picker/dialog_content_component.rb | 8 ++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/components/work_packages/date_picker/dialog_content_component.html.erb b/app/components/work_packages/date_picker/dialog_content_component.html.erb index 9cf28a83681b..38183585d7d0 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.html.erb +++ b/app/components/work_packages/date_picker/dialog_content_component.html.erb @@ -136,7 +136,10 @@ I18n.t("button_cancel") end - footer.with_component(Primer::ButtonComponent.new(scheme: :primary, type: :submit, form: DIALOG_FORM_ID)) do + footer.with_component(Primer::ButtonComponent.new(scheme: :primary, + type: :submit, + form: DIALOG_FORM_ID, + disabled: !schedulable?)) do I18n.t("button_save") end end diff --git a/app/components/work_packages/date_picker/dialog_content_component.rb b/app/components/work_packages/date_picker/dialog_content_component.rb index f593486d9aa3..43d086f84f66 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.rb +++ b/app/components/work_packages/date_picker/dialog_content_component.rb @@ -64,8 +64,8 @@ def precedes_relations @precedes_relations ||= work_package.precedes_relations end - def follow_relations - @follow_relations ||= work_package.follows_relations + def follows_relations + @follows_relations ||= work_package.follows_relations end def children @@ -80,7 +80,7 @@ def additional_tabs [ { key: "predecessors", - relations: follow_relations + relations: follows_relations }, { key: "successors", @@ -95,7 +95,7 @@ def additional_tabs end def schedulable? - @schedule_manually || precedes_relations.any? + @schedule_manually || follows_relations.any? end def parse_focused_field(focused_field) From 3e56e7e6a363fed562ca4c3eeb78ede9765e4b5f Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 13 Jan 2025 15:05:50 +0100 Subject: [PATCH 31/92] Provide Blankslates for empty tabs --- .../dialog_content_component.html.erb | 22 +++++++++++++------ config/locales/en.yml | 10 +++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/app/components/work_packages/date_picker/dialog_content_component.html.erb b/app/components/work_packages/date_picker/dialog_content_component.html.erb index 38183585d7d0..03bc38a9280c 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.html.erb +++ b/app/components/work_packages/date_picker/dialog_content_component.html.erb @@ -114,15 +114,23 @@ tab_content.with_text { I18n.t("work_packages.datepicker_modal.tabs.#{tab[:key]}") } tab_content.with_counter(count: tab[:relations].count) tab_content.with_panel do - render(border_box_container(padding: :condensed)) do |box| - tab[:relations].visible.each do |relation| - box.with_row(scheme: :default) do - render(WorkPackageRelationsTab::RelationComponent.new(work_package:, - relation: (relation unless tab[:is_child_relation?]), - child: (relation if tab[:is_child_relation?]), - editable: false)) + if tab[:relations].any? + render(border_box_container(padding: :condensed)) do |box| + tab[:relations].visible.each do |relation| + box.with_row(scheme: :default) do + render(WorkPackageRelationsTab::RelationComponent.new(work_package:, + relation: (relation unless tab[:is_child_relation?]), + child: (relation if tab[:is_child_relation?]), + editable: false)) + end end end + else + render(Primer::Beta::Blankslate.new(border: true)) do |component| + component.with_visual_icon(icon: :book, size: :medium) + component.with_heading(tag: :h2) { I18n.t("work_packages.datepicker_modal.tabs.blankslate.#{tab[:key]}.title") } + component.with_description { I18n.t("work_packages.datepicker_modal.tabs.blankslate.#{tab[:key]}.description") } + end end end end diff --git a/config/locales/en.yml b/config/locales/en.yml index f3e55436f380..53afc11e78a3 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -720,6 +720,16 @@ en: dates: "Dates" predecessors: "Predecessors" successors: "Successors" + blankslate: + predecessors: + title: "No predecessors" + description: "This work package does not have any predecessors." + successors: + title: "No successors" + description: "This work package does not have any successors." + children: + title: "No children" + description: "This work package does not have any children." x_descendants: one: "One descendant work package" From 4ce45d6afe8f56d054985c6e8162d812defab15b Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 13 Jan 2025 15:06:13 +0100 Subject: [PATCH 32/92] Avoid that the Dialog is jumping around when switching tabs and the content size changes --- app/components/_index.sass | 1 + .../date_picker/dialog_content_component.html.erb | 2 +- .../work_packages/date_picker/dialog_content_component.sass | 4 ++++ 3 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 app/components/work_packages/date_picker/dialog_content_component.sass diff --git a/app/components/_index.sass b/app/components/_index.sass index 42597cf0ed83..23c9000d86f3 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -6,6 +6,7 @@ @import "work_packages/activities_tab/journals/item_component/details" @import "work_packages/activities_tab/journals/item_component/add_reactions" @import "work_packages/activities_tab/journals/item_component/reactions" +@import "work_packages/date_picker/dialog_content_component" @import "shares/modal_body_component" @import "work_packages/reminder/modal_body_component" @import "shares/invite_user_form_component" diff --git a/app/components/work_packages/date_picker/dialog_content_component.html.erb b/app/components/work_packages/date_picker/dialog_content_component.html.erb index 03bc38a9280c..788403edb9c5 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.html.erb +++ b/app/components/work_packages/date_picker/dialog_content_component.html.erb @@ -7,7 +7,7 @@ collection.with_component(WorkPackages::DatePicker::BannerComponent.new(work_package:, manually_scheduled: schedule_manually)) end - collection.with_component(Primer::Alpha::Dialog::Body.new(classes: "Overlay-body_autocomplete_height")) do + collection.with_component(Primer::Alpha::Dialog::Body.new(classes: "wp-datepicker-dialog--content")) do render(Primer::Alpha::UnderlinePanels.new(label: I18n.t("work_packages.datepicker_modal.tabs.aria_label"))) do |component| component.with_tab(selected: true, id: "wp-datepicker-dialog--content-tab--dates") do |tab| tab.with_text { I18n.t("work_packages.datepicker_modal.tabs.dates") } diff --git a/app/components/work_packages/date_picker/dialog_content_component.sass b/app/components/work_packages/date_picker/dialog_content_component.sass new file mode 100644 index 000000000000..8b01723f059f --- /dev/null +++ b/app/components/work_packages/date_picker/dialog_content_component.sass @@ -0,0 +1,4 @@ +.wp-datepicker-dialog--content + // We set a fixed height for this dialog zo avoid that it jumps around when the tabs are switched or errors shown + min-height: 525px + min-width: 600px From d5e0a08461b6e0079c54a82f358c7768cb5a5329 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Tue, 14 Jan 2025 14:27:07 +0100 Subject: [PATCH 33/92] Do not show the banner when there are no relations in manual mode --- .../date_picker/dialog_content_component.html.erb | 2 +- .../work_packages/date_picker/dialog_content_component.rb | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/components/work_packages/date_picker/dialog_content_component.html.erb b/app/components/work_packages/date_picker/dialog_content_component.html.erb index 788403edb9c5..f4583acfd89f 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.html.erb +++ b/app/components/work_packages/date_picker/dialog_content_component.html.erb @@ -3,7 +3,7 @@ component_wrapper(data: { "application-target": "dynamic", controller: "work-packages--date-picker--preview" }) do component_collection do |collection| - if schedulable? + if schedulable? && has_relations? collection.with_component(WorkPackages::DatePicker::BannerComponent.new(work_package:, manually_scheduled: schedule_manually)) end diff --git a/app/components/work_packages/date_picker/dialog_content_component.rb b/app/components/work_packages/date_picker/dialog_content_component.rb index 43d086f84f66..7f63f45ad9d3 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.rb +++ b/app/components/work_packages/date_picker/dialog_content_component.rb @@ -98,6 +98,10 @@ def schedulable? @schedule_manually || follows_relations.any? end + def has_relations? + precedes_relations.any? || follows_relations.any? || children.any? + end + def parse_focused_field(focused_field) %i[start_date due_date duration].include?(focused_field) ? focused_field : :start_date end From cf1c9b08accfdffdc4d1d85d992c2ce4ad226b5c Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Tue, 14 Jan 2025 14:45:10 +0100 Subject: [PATCH 34/92] Start on mobile layout for Datepicker --- app/components/_index.sass | 1 + .../date_picker/banner_component.html.erb | 15 ++++++---- .../date_picker/banner_component.rb | 28 +++++++++++++++++++ .../date_picker/banner_component.sass | 9 ++++++ .../dialog_content_component.html.erb | 4 +-- .../date_picker/dialog_content_component.sass | 17 ++++++++--- config/locales/en.yml | 4 +++ 7 files changed, 67 insertions(+), 11 deletions(-) create mode 100644 app/components/work_packages/date_picker/banner_component.sass diff --git a/app/components/_index.sass b/app/components/_index.sass index 23c9000d86f3..85b20dad25c0 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -6,6 +6,7 @@ @import "work_packages/activities_tab/journals/item_component/details" @import "work_packages/activities_tab/journals/item_component/add_reactions" @import "work_packages/activities_tab/journals/item_component/reactions" +@import "work_packages/date_picker/banner_component" @import "work_packages/date_picker/dialog_content_component" @import "shares/modal_body_component" @import "work_packages/reminder/modal_body_component" diff --git a/app/components/work_packages/date_picker/banner_component.html.erb b/app/components/work_packages/date_picker/banner_component.html.erb index 24583d5eeeaa..6f7bde116710 100644 --- a/app/components/work_packages/date_picker/banner_component.html.erb +++ b/app/components/work_packages/date_picker/banner_component.html.erb @@ -1,10 +1,15 @@ <%= - render(Primer::Alpha::Banner.new(scheme:, - full: true, - icon: :info, - description:, - classes: "rounded-top-2")) do |banner| + render(Primer::Alpha::Banner.new(description:, + classes: "wp-datepicker--banner_desktop rounded-top-2", + **banner_options)) do |banner| banner.with_action_button(tag: :a, href: link, target: "_blank") { I18n.t("work_packages.datepicker_modal.show_relations") } title end %> +<%= + render(Primer::Alpha::Banner.new(description: mobile_description, + classes: "wp-datepicker--banner_mobile rounded-top-2", + **banner_options)) do + mobile_title + end +%> diff --git a/app/components/work_packages/date_picker/banner_component.rb b/app/components/work_packages/date_picker/banner_component.rb index 7a1d2f70626a..a2305a4e14aa 100644 --- a/app/components/work_packages/date_picker/banner_component.rb +++ b/app/components/work_packages/date_picker/banner_component.rb @@ -70,6 +70,14 @@ def title end end + def mobile_title + if @manually_scheduled + I18n.t("work_packages.datepicker_modal.banner.title.manual_mobile") + else + I18n.t("work_packages.datepicker_modal.banner.title.automatic_mobile") + end + end + def description if @manually_scheduled if children.any? @@ -87,6 +95,18 @@ def description button_name: I18n.t("work_packages.datepicker_modal.show_relations")) end + def mobile_description + text = if @manually_scheduled + I18n.t("work_packages.datepicker_modal.banner.description.manual_mobile") + else + I18n.t("work_packages.datepicker_modal.banner.description.automatic_mobile") + end + + "#{text} #{render(Primer::Beta::Link.new(tag: :a, href: link, target: '_blank')) do + I18n.t('work_packages.datepicker_modal.show_relations') + end}".html_safe + end + def overlapping_predecessor? predecessor_work_packages.any? { |wp| wp.due_date.after?(@work_package.start_date) } end @@ -117,6 +137,14 @@ def all_relational_wp_ids .flatten .uniq end + + def banner_options + { + scheme:, + full: true, + icon: :info + } + end end end end diff --git a/app/components/work_packages/date_picker/banner_component.sass b/app/components/work_packages/date_picker/banner_component.sass new file mode 100644 index 000000000000..0c4e19bb178b --- /dev/null +++ b/app/components/work_packages/date_picker/banner_component.sass @@ -0,0 +1,9 @@ +.wp-datepicker--banner + + @media screen and (min-width: $breakpoint-sm) + &_mobile + display: none + + @media screen and (max-width: $breakpoint-sm) + &_desktop + display: none diff --git a/app/components/work_packages/date_picker/dialog_content_component.html.erb b/app/components/work_packages/date_picker/dialog_content_component.html.erb index f4583acfd89f..0016c0b5973b 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.html.erb +++ b/app/components/work_packages/date_picker/dialog_content_component.html.erb @@ -21,7 +21,7 @@ ) do |f| flex_layout do |body| body.with_row(mb: 3) do - flex_layout(align_items: :flex_end, justify_content: :space_between) do |first_row| + flex_layout(classes: "wp-datepicker-dialog--form-block", align_items: :flex_end, justify_content: :space_between) do |first_row| first_row.with_column do concat(render(Primer::Alpha::FormControl.new(label: I18n.t("work_packages.datepicker_modal.mode.title"))) do |component| component.with_input do @@ -73,7 +73,7 @@ end if schedulable? - body.with_row(mb: 3) do + body.with_row(mb: 3, classes: "FormControl-horizontalGroup--sm-vertical") do render(WorkPackages::DatePicker::Form.new(f, work_package:, focused_field:, diff --git a/app/components/work_packages/date_picker/dialog_content_component.sass b/app/components/work_packages/date_picker/dialog_content_component.sass index 8b01723f059f..be5418390ccf 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.sass +++ b/app/components/work_packages/date_picker/dialog_content_component.sass @@ -1,4 +1,13 @@ -.wp-datepicker-dialog--content - // We set a fixed height for this dialog zo avoid that it jumps around when the tabs are switched or errors shown - min-height: 525px - min-width: 600px +.wp-datepicker-dialog + + @media screen and (min-width: $breakpoint-sm) + &--content + // We set a fixed height for this dialog zo avoid that it jumps around when the tabs are switched or errors shown + min-height: 525px + min-width: 600px + + @media screen and (max-width: $breakpoint-sm) + &--form-block + flex-direction: column !important + align-items: flex-start !important + row-gap: 1rem diff --git a/config/locales/en.yml b/config/locales/en.yml index 53afc11e78a3..a6798f43e5c7 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -696,13 +696,17 @@ en: datepicker_modal: banner: description: + automatic_mobile: "Start date derived." click_on_show_relations_to_open_gantt: 'Click on "%{button_name}" for Gantt overview.' + manual_mobile: "Ignoring relations." manual_gap_between_predecessors: "There is a gap between this and all predecessors." manual_overlap_with_predecessors: "Overlaps with at least one predecessor." manual_with_children: "This has child work package but their start dates are ignored." title: + automatic_mobile: "Automatically scheduled." automatic_with_children: "The dates are determined by child work packages." automatic_with_predecessor: "The start date is set by a predecessor." + manual_mobile: "Manually scheduled." manually_scheduled: "Manually scheduled. Dates not affected by relations." blankslate: title: "No predecessors" From 6663e37d2e6a0304548dbdb64361add18f60bfdc Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Wed, 15 Jan 2025 15:11:56 +0100 Subject: [PATCH 35/92] Extract code into a separate component for better structure --- .../dialog_content_component.html.erb | 100 ++---------------- .../date_picker/dialog_content_component.rb | 21 +--- .../form_content_component.html.erb | 82 ++++++++++++++ .../date_picker/form_content_component.rb | 77 ++++++++++++++ .../date_picker/{form.rb => date_form.rb} | 6 +- .../date_picker/initial_values_form.rb | 1 + 6 files changed, 172 insertions(+), 115 deletions(-) create mode 100644 app/components/work_packages/date_picker/form_content_component.html.erb create mode 100644 app/components/work_packages/date_picker/form_content_component.rb rename app/forms/work_packages/date_picker/{form.rb => date_form.rb} (94%) diff --git a/app/components/work_packages/date_picker/dialog_content_component.html.erb b/app/components/work_packages/date_picker/dialog_content_component.html.erb index 0016c0b5973b..7735e5aaf3f8 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.html.erb +++ b/app/components/work_packages/date_picker/dialog_content_component.html.erb @@ -12,100 +12,12 @@ component.with_tab(selected: true, id: "wp-datepicker-dialog--content-tab--dates") do |tab| tab.with_text { I18n.t("work_packages.datepicker_modal.tabs.dates") } tab.with_panel do - primer_form_with( - model: work_package, - url: submit_path, - id: DIALOG_FORM_ID, - data: { "work-packages--date-picker--preview-target": "form" }, - html: { autocomplete: "off" }, - ) do |f| - flex_layout do |body| - body.with_row(mb: 3) do - flex_layout(classes: "wp-datepicker-dialog--form-block", align_items: :flex_end, justify_content: :space_between) do |first_row| - first_row.with_column do - concat(render(Primer::Alpha::FormControl.new(label: I18n.t("work_packages.datepicker_modal.mode.title"))) do |component| - component.with_input do - render(Primer::Alpha::SegmentedControl.new("aria-label": I18n.t("work_packages.datepicker_modal.mode.title"))) do |control| - control.with_item(tag: :a, - href: work_package_datepicker_dialog_content_path(params.merge(schedule_manually: true).permit!), - data: { turbo_stream: true }, - label: I18n.t("work_packages.datepicker_modal.mode.manual"), - title: I18n.t("work_packages.datepicker_modal.mode.manual"), - selected: schedule_manually) - control.with_item(tag: :a, - href: work_package_datepicker_dialog_content_path(params.merge(schedule_manually: false).permit!), - data: { turbo_stream: true }, - disabled: work_package.new_record?, - label: I18n.t("work_packages.datepicker_modal.mode.automatic"), - title: I18n.t("work_packages.datepicker_modal.mode.automatic"), - selected: !schedule_manually) - end - end - end) - concat( - f.hidden_field(:work_package, - name: "work_package[schedule_manually]", - value: schedule_manually) - ) - concat( - f.hidden_field(:work_package, - name: "work_package[schedule_manually_touched]", - value: params[:schedule_manually].present?) - ) - concat( - f.hidden_field(:work_package, - name: "work_package[initial][schedule_manually]", - value: work_package.schedule_manually) - ) - end - - first_row.with_column(mb: 1) do - render(Primer::Alpha::CheckBox.new(name: "work_package[ignore_non_working_days]", - label: I18n.t("work_packages.datepicker_modal.ignore_non_working_days.title"), - checked: !work_package.ignore_non_working_days, - disabled: disabled?, - value: 0, - unchecked_value: 1, - data: { "work-packages--date-picker--preview-target": "fieldInput", - action: "work-packages--date-picker--preview#markFieldAsTouched" })) - end - end - end - - if schedulable? - body.with_row(mb: 3, classes: "FormControl-horizontalGroup--sm-vertical") do - render(WorkPackages::DatePicker::Form.new(f, - work_package:, - focused_field:, - touched_field_map:, - disabled: disabled?)) - end - - body.with_row(mb: 3) do - helpers.angular_component_tag "opce-wp-modal-date-picker", - inputs: { - start_date: work_package.start_date, - due_date: work_package.due_date, - ignore_non_working_days: work_package.ignore_non_working_days, - schedule_manually:, - is_schedulable: !disabled?, - is_milestone: work_package.is_milestone?, - field_name: focused_field, - start_date_field_id: "work_package_start_date", - due_date_field_id: "work_package_due_date" - } - end - else - body.with_row(mb: 3) do - render(Primer::Beta::Blankslate.new(border: true)) do |component| - component.with_visual_icon(icon: :book, size: :medium) - component.with_heading(tag: :h2) { I18n.t("work_packages.datepicker_modal.blankslate.title") } - component.with_description { I18n.t("work_packages.datepicker_modal.blankslate.description") } - end - end - end - end - end + render(WorkPackages::DatePicker::FormContentComponent.new(form_id: DIALOG_FORM_ID, + schedulable: schedulable?, + work_package:, + schedule_manually:, + focused_field:, + touched_field_map:)) end end diff --git a/app/components/work_packages/date_picker/dialog_content_component.rb b/app/components/work_packages/date_picker/dialog_content_component.rb index 7f63f45ad9d3..c1250092b875 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.rb +++ b/app/components/work_packages/date_picker/dialog_content_component.rb @@ -43,23 +43,12 @@ def initialize(work_package:, schedule_manually: true, focused_field: :start_dat @work_package = work_package @schedule_manually = ActiveModel::Type::Boolean.new.cast(schedule_manually) - @focused_field = parse_focused_field(focused_field) + @focused_field = focused_field @touched_field_map = touched_field_map end private - def submit_path - if work_package.new_record? - url_for(controller: "work_packages/date_picker", - action: "create") - else - url_for(controller: "work_packages/date_picker", - action: "update", - work_package_id: work_package.id) - end - end - def precedes_relations @precedes_relations ||= work_package.precedes_relations end @@ -72,10 +61,6 @@ def children @children ||= work_package.children end - def disabled? - !schedule_manually - end - def additional_tabs [ { @@ -101,10 +86,6 @@ def schedulable? def has_relations? precedes_relations.any? || follows_relations.any? || children.any? end - - def parse_focused_field(focused_field) - %i[start_date due_date duration].include?(focused_field) ? focused_field : :start_date - end end end end diff --git a/app/components/work_packages/date_picker/form_content_component.html.erb b/app/components/work_packages/date_picker/form_content_component.html.erb new file mode 100644 index 000000000000..581fc7917d91 --- /dev/null +++ b/app/components/work_packages/date_picker/form_content_component.html.erb @@ -0,0 +1,82 @@ +<%= + primer_form_with( + model: work_package, + url: submit_path, + id: form_id, + data: { "work-packages--date-picker--preview-target": "form" }, + html: { autocomplete: "off" }, + ) do |f| + flex_layout do |body| + body.with_row(mb: 3) do + flex_layout(classes: "wp-datepicker-dialog--form-block", align_items: :flex_end, justify_content: :space_between) do |first_row| + first_row.with_column do + render(Primer::Alpha::FormControl.new(label: I18n.t("work_packages.datepicker_modal.mode.title"))) do |component| + component.with_input do + render(Primer::Alpha::SegmentedControl.new("aria-label": I18n.t("work_packages.datepicker_modal.mode.title"))) do |control| + control.with_item(tag: :a, + href: work_package_datepicker_dialog_content_path(params.merge(schedule_manually: true).permit!), + data: { turbo_stream: true }, + label: I18n.t("work_packages.datepicker_modal.mode.manual"), + title: I18n.t("work_packages.datepicker_modal.mode.manual"), + selected: schedule_manually) + control.with_item(tag: :a, + href: work_package_datepicker_dialog_content_path(params.merge(schedule_manually: false).permit!), + data: { turbo_stream: true }, + disabled: work_package.new_record?, + label: I18n.t("work_packages.datepicker_modal.mode.automatic"), + title: I18n.t("work_packages.datepicker_modal.mode.automatic"), + selected: !schedule_manually) + end + end + end + end + + first_row.with_column(mb: 1) do + render(Primer::Alpha::CheckBox.new(name: "work_package[ignore_non_working_days]", + label: I18n.t("work_packages.datepicker_modal.ignore_non_working_days.title"), + checked: !work_package.ignore_non_working_days, + disabled: disabled?, + value: 0, + unchecked_value: 1, + data: { "work-packages--date-picker--preview-target": "fieldInput", + action: "work-packages--date-picker--preview#markFieldAsTouched" })) + end + end + end + + if schedulable + body.with_row(mb: 3, classes: "FormControl-horizontalGroup--sm-vertical") do + render(WorkPackages::DatePicker::DateForm.new(f, + work_package:, + schedule_manually:, + focused_field:, + touched_field_map:, + disabled: disabled?)) + end + + body.with_row(mb: 3) do + helpers.angular_component_tag "opce-wp-modal-date-picker", + inputs: { + start_date: work_package.start_date, + due_date: work_package.due_date, + ignore_non_working_days: work_package.ignore_non_working_days, + schedule_manually:, + is_schedulable: !disabled?, + is_milestone: work_package.is_milestone?, + field_name: focused_field, + start_date_field_id: "work_package_start_date", + due_date_field_id: "work_package_due_date" + } + end + else + body.with_row(mb: 3) do + render(Primer::Beta::Blankslate.new(border: true)) do |component| + component.with_visual_icon(icon: :book, size: :medium) + component.with_heading(tag: :h2) { I18n.t("work_packages.datepicker_modal.blankslate.title") } + component.with_description { I18n.t("work_packages.datepicker_modal.blankslate.description") } + end + end + end + end + end +%> diff --git a/app/components/work_packages/date_picker/form_content_component.rb b/app/components/work_packages/date_picker/form_content_component.rb new file mode 100644 index 000000000000..52fe41610b23 --- /dev/null +++ b/app/components/work_packages/date_picker/form_content_component.rb @@ -0,0 +1,77 @@ +#-- 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. +#++ + +# frozen_string_literal: true + +module WorkPackages + module DatePicker + class FormContentComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + attr_accessor :form_id, :schedulable, :work_package, :schedule_manually, :focused_field, :touched_field_map + + def initialize(form_id:, + schedulable:, + work_package:, + schedule_manually: true, + focused_field: :start_date, + touched_field_map: {}) + super + + @form_id = form_id + @schedulable = schedulable + @work_package = work_package + @schedule_manually = ActiveModel::Type::Boolean.new.cast(schedule_manually) + @focused_field = parse_focused_field(focused_field) + @touched_field_map = touched_field_map + end + + private + + def submit_path + if work_package.new_record? + url_for(controller: "work_packages/date_picker", + action: "create") + else + url_for(controller: "work_packages/date_picker", + action: "update", + work_package_id: work_package.id) + end + end + + def disabled? + !schedule_manually + end + + def parse_focused_field(focused_field) + %i[start_date due_date duration].include?(focused_field) ? focused_field : :start_date + end + end + end +end diff --git a/app/forms/work_packages/date_picker/form.rb b/app/forms/work_packages/date_picker/date_form.rb similarity index 94% rename from app/forms/work_packages/date_picker/form.rb rename to app/forms/work_packages/date_picker/date_form.rb index 4ac60fb81040..f7848a792295 100644 --- a/app/forms/work_packages/date_picker/form.rb +++ b/app/forms/work_packages/date_picker/date_form.rb @@ -28,7 +28,7 @@ module WorkPackages module DatePicker - class Form < ApplicationForm + class DateForm < ApplicationForm ## # Primer::Forms::BaseComponent or ApplicationForm will always autofocus the # first input field with an error present on it. Despite this behavior being @@ -49,12 +49,14 @@ def before_render attr_reader :work_package def initialize(work_package:, + schedule_manually:, disabled:, focused_field: :start_date, touched_field_map: {}) super() @work_package = work_package + @schedule_manually = schedule_manually @is_milestone = work_package.milestone? @focused_field = focused_field @touched_field_map = touched_field_map @@ -63,6 +65,7 @@ def initialize(work_package:, form do |query_form| query_form.group(layout: :horizontal) do |group| + group.hidden(name: "schedule_manually", value: @schedule_manually) if @is_milestone text_field(group, name: :start_date, label: I18n.t("attributes.date")) @@ -79,6 +82,7 @@ def initialize(work_package:, end hidden_touched_field(group, name: :ignore_non_working_days) + hidden_touched_field(group, name: :schedule_manually) group.fields_for(:initial) do |builder| WorkPackages::DatePicker::InitialValuesForm.new(builder, work_package:) diff --git a/app/forms/work_packages/date_picker/initial_values_form.rb b/app/forms/work_packages/date_picker/initial_values_form.rb index c4ff87b163ba..4dd40a33dd57 100644 --- a/app/forms/work_packages/date_picker/initial_values_form.rb +++ b/app/forms/work_packages/date_picker/initial_values_form.rb @@ -42,6 +42,7 @@ def initialize(work_package:) hidden_initial_field(form, name: :due_date) hidden_initial_field(form, name: :duration) hidden_initial_field(form, name: :ignore_non_working_days) + hidden_initial_field(form, name: :schedule_manually) end private From 0003e1fa0fbbd7752ae415cef9662883319c4292 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Wed, 15 Jan 2025 15:12:33 +0100 Subject: [PATCH 36/92] Hide the UnderlineNav on mobile --- .../date_picker/dialog_content_component.html.erb | 3 ++- .../work_packages/date_picker/dialog_content_component.sass | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/components/work_packages/date_picker/dialog_content_component.html.erb b/app/components/work_packages/date_picker/dialog_content_component.html.erb index 7735e5aaf3f8..6e2ba410d1fc 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.html.erb +++ b/app/components/work_packages/date_picker/dialog_content_component.html.erb @@ -8,7 +8,8 @@ end collection.with_component(Primer::Alpha::Dialog::Body.new(classes: "wp-datepicker-dialog--content")) do - render(Primer::Alpha::UnderlinePanels.new(label: I18n.t("work_packages.datepicker_modal.tabs.aria_label"))) do |component| + render(Primer::Alpha::UnderlinePanels.new(label: I18n.t("work_packages.datepicker_modal.tabs.aria_label"), + classes: "wp-datepicker-dialog--UnderlineNav")) do |component| component.with_tab(selected: true, id: "wp-datepicker-dialog--content-tab--dates") do |tab| tab.with_text { I18n.t("work_packages.datepicker_modal.tabs.dates") } tab.with_panel do diff --git a/app/components/work_packages/date_picker/dialog_content_component.sass b/app/components/work_packages/date_picker/dialog_content_component.sass index be5418390ccf..a4227a8c1fb2 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.sass +++ b/app/components/work_packages/date_picker/dialog_content_component.sass @@ -7,6 +7,11 @@ min-width: 600px @media screen and (max-width: $breakpoint-sm) + &--UnderlineNav + display: none !important + &--content + padding-top: var(--stack-padding-normal) + &--form-block flex-direction: column !important align-items: flex-start !important From 051c37271ec55d2ae5d255c64f47977d709673cc Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Thu, 16 Jan 2025 15:03:21 +0100 Subject: [PATCH 37/92] Take care that the datepicker scrolls on mobile --- .../dialog_content_component.html.erb | 5 +++-- .../date_picker/dialog_content_component.sass | 22 ++++++++++++++----- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/app/components/work_packages/date_picker/dialog_content_component.html.erb b/app/components/work_packages/date_picker/dialog_content_component.html.erb index 6e2ba410d1fc..510b69854b57 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.html.erb +++ b/app/components/work_packages/date_picker/dialog_content_component.html.erb @@ -1,13 +1,14 @@ <%= content_tag("turbo-frame", id: "wp-datepicker-dialog--content") do component_wrapper(data: { "application-target": "dynamic", - controller: "work-packages--date-picker--preview" }) do + controller: "work-packages--date-picker--preview" }, + class: "wp-datepicker-dialog--content") do component_collection do |collection| if schedulable? && has_relations? collection.with_component(WorkPackages::DatePicker::BannerComponent.new(work_package:, manually_scheduled: schedule_manually)) end - collection.with_component(Primer::Alpha::Dialog::Body.new(classes: "wp-datepicker-dialog--content")) do + collection.with_component(Primer::Alpha::Dialog::Body.new(classes: "wp-datepicker-dialog--body")) do render(Primer::Alpha::UnderlinePanels.new(label: I18n.t("work_packages.datepicker_modal.tabs.aria_label"), classes: "wp-datepicker-dialog--UnderlineNav")) do |component| component.with_tab(selected: true, id: "wp-datepicker-dialog--content-tab--dates") do |tab| diff --git a/app/components/work_packages/date_picker/dialog_content_component.sass b/app/components/work_packages/date_picker/dialog_content_component.sass index a4227a8c1fb2..99f9dffd1028 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.sass +++ b/app/components/work_packages/date_picker/dialog_content_component.sass @@ -1,18 +1,28 @@ -.wp-datepicker-dialog - - @media screen and (min-width: $breakpoint-sm) - &--content +@media screen and (min-width: $breakpoint-sm) + .wp-datepicker-dialog + &--body // We set a fixed height for this dialog zo avoid that it jumps around when the tabs are switched or errors shown min-height: 525px min-width: 600px - @media screen and (max-width: $breakpoint-sm) +@media screen and (max-width: $breakpoint-sm) + .wp-datepicker-dialog &--UnderlineNav display: none !important - &--content + &--body padding-top: var(--stack-padding-normal) &--form-block flex-direction: column !important align-items: flex-start !important row-gap: 1rem + + // re-implement the scrolling behaviour. Due to the turbo-frame element in between the default Dialog body scrolling does not work + .wp-datepicker-dialog--content + display: flex + flex-direction: column + overflow: auto + + .wp-datepicker-dialog--content, + #wp-datepicker-dialog--content + height: inherit From becfbae0eef816a5ce6f94a3f1801a79d731a9e4 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Fri, 17 Jan 2025 13:24:42 +0100 Subject: [PATCH 38/92] Add "Today" link for Datepicker fields --- .../work_packages/date_picker/date_form.rb | 14 +++++++++++ config/locales/js-en.yml | 3 --- .../date-picker/preview.controller.ts | 23 +++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/app/forms/work_packages/date_picker/date_form.rb b/app/forms/work_packages/date_picker/date_form.rb index f7848a792295..56032509ad25 100644 --- a/app/forms/work_packages/date_picker/date_form.rb +++ b/app/forms/work_packages/date_picker/date_form.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # -- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -98,12 +100,24 @@ def text_field(group, name:, label:, disabled: @disabled) value: field_value(name), disabled:, label:, + caption: caption(name), validation_message: validation_message(name) ) group.text_field(**text_field_options) end + def caption(field) + text = I18n.t(:label_today).capitalize + + return text if @disabled + + render(Primer::Beta::Link.new(href: "", data: { + action: "click->work-packages--date-picker--preview#setTodayForField", + "work-packages--date-picker--preview-field-reference-param": "work_package_#{field}" + })) { text } + end + def hidden_touched_field(group, name:) group.hidden(name: :"#{name}_touched", value: touched(name), diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index 0427cb1e6a92..1d1c175f3308 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -932,9 +932,6 @@ en: project_menu_details: "Details" - scheduling: - manual: "Manual scheduling" - sort: sorted_asc: "Ascending sort applied, " sorted_dsc: "Descending sort applied, " 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 index eeb902002b84..b1ec8e6122ae 100644 --- 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 @@ -29,8 +29,18 @@ */ 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); } @@ -70,4 +80,17 @@ export default class PreviewController extends DialogPreviewController { return field.value; } + + 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')); + } + } + } } From bba1edc496207a2ba4a627bdac8410e87969e2cd Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Fri, 17 Jan 2025 15:09:20 +0100 Subject: [PATCH 39/92] Remove unused sass styles --- .../datepicker/styles/datepicker.modal.sass | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/frontend/src/app/shared/components/datepicker/styles/datepicker.modal.sass b/frontend/src/app/shared/components/datepicker/styles/datepicker.modal.sass index 4c8ad4a35360..bf33092898c9 100644 --- a/frontend/src/app/shared/components/datepicker/styles/datepicker.modal.sass +++ b/frontend/src/app/shared/components/datepicker/styles/datepicker.modal.sass @@ -13,15 +13,6 @@ // will be overwritten on mobile max-width: 320px - &_wide - max-width: 600px - - &--dates-container - display: grid - grid-template-columns: 1fr 1fr 150px - grid-gap: $spot-spacing-1 - margin: 0 $spot-spacing-1 - &--date-field &_current, &_current:hover @@ -32,12 +23,6 @@ border: 2px solid var(--control-checked-color) margin: -1px - &--date-container - display: inline-grid - - &--hidden-link - visibility: hidden - &--flatpickr-instance.inline margin: 0.5rem auto 0 auto !important overflow: hidden @@ -51,13 +36,6 @@ &--flatpickr-instance align-self: center - &--dates-container - grid-template-columns: 1fr 1fr - - &--date-form - &:only-child - grid-column: 1 / 3 - .flatpickr-wrapper // Make flatpickr behave correctly when it is instantiated // inside a dialog using the static: true option. From fa3d020b4ded6eb31e9f26a0cc763793d65ac83a Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Fri, 17 Jan 2025 15:10:27 +0100 Subject: [PATCH 40/92] Start to re-add some test selectors and adapt tests to new structure --- .../dialog_content_component.html.erb | 3 ++- .../work_packages/date_picker/date_form.rb | 6 ++++- .../datepicker/work_package_datepicker.rb | 22 ++++++------------- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/app/components/work_packages/date_picker/dialog_content_component.html.erb b/app/components/work_packages/date_picker/dialog_content_component.html.erb index 510b69854b57..07abe2992cd2 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.html.erb +++ b/app/components/work_packages/date_picker/dialog_content_component.html.erb @@ -1,7 +1,8 @@ <%= content_tag("turbo-frame", id: "wp-datepicker-dialog--content") do component_wrapper(data: { "application-target": "dynamic", - controller: "work-packages--date-picker--preview" }, + controller: "work-packages--date-picker--preview", + test_selector: "op-datepicker-modal" }, class: "wp-datepicker-dialog--content") do component_collection do |collection| if schedulable? && has_relations? diff --git a/app/forms/work_packages/date_picker/date_form.rb b/app/forms/work_packages/date_picker/date_form.rb index 56032509ad25..5e41795ae943 100644 --- a/app/forms/work_packages/date_picker/date_form.rb +++ b/app/forms/work_packages/date_picker/date_form.rb @@ -101,6 +101,9 @@ def text_field(group, name:, label:, disabled: @disabled) disabled:, label:, caption: caption(name), + data: { + test_selector: "op-datepicker-modal--#{name.to_s.dasherize}-field" + }, validation_message: validation_message(name) ) @@ -114,7 +117,8 @@ def caption(field) render(Primer::Beta::Link.new(href: "", data: { action: "click->work-packages--date-picker--preview#setTodayForField", - "work-packages--date-picker--preview-field-reference-param": "work_package_#{field}" + "work-packages--date-picker--preview-field-reference-param": "work_package_#{field}", + test_selector: "op-datepicker-modal--#{field.to_s.dasherize}-field--today" })) { text } end diff --git a/spec/support/components/datepicker/work_package_datepicker.rb b/spec/support/components/datepicker/work_package_datepicker.rb index a00c759ae77e..b54d31319c59 100644 --- a/spec/support/components/datepicker/work_package_datepicker.rb +++ b/spec/support/components/datepicker/work_package_datepicker.rb @@ -29,7 +29,7 @@ def expect_duration(value) I18n.t("js.units.day", count: 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 @@ -59,19 +59,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) @@ -95,7 +95,7 @@ def expect_due_highlighted end def duration_field - container.find_field "duration" + container.find_field "work_package[duration]" end def focus_duration @@ -103,16 +103,8 @@ 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 + page.within("[data-test-selector='datepicker-#{date}-date-field--today']") do + find("a", text: "Today").click end end From 35c0bbb2599e3b94c6ea4f41bed00e751fb0418c Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Fri, 17 Jan 2025 15:18:32 +0100 Subject: [PATCH 41/92] Always allow editing the duration --- app/forms/work_packages/date_picker/date_form.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/forms/work_packages/date_picker/date_form.rb b/app/forms/work_packages/date_picker/date_form.rb index 5e41795ae943..84dd59c0b3cb 100644 --- a/app/forms/work_packages/date_picker/date_form.rb +++ b/app/forms/work_packages/date_picker/date_form.rb @@ -98,7 +98,7 @@ def text_field(group, name:, label:, disabled: @disabled) text_field_options = default_field_options(name).merge( name:, value: field_value(name), - disabled:, + disabled: name == :duration ? false : disabled, # duration is always editable label:, caption: caption(name), data: { From 5fe80efcbc79a7133a36321bc00975a6cea4a660 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Fri, 17 Jan 2025 15:27:52 +0100 Subject: [PATCH 42/92] Set the test_selector correctly --- app/forms/work_packages/date_picker/date_form.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/forms/work_packages/date_picker/date_form.rb b/app/forms/work_packages/date_picker/date_form.rb index 84dd59c0b3cb..cda61c280811 100644 --- a/app/forms/work_packages/date_picker/date_form.rb +++ b/app/forms/work_packages/date_picker/date_form.rb @@ -101,9 +101,6 @@ def text_field(group, name:, label:, disabled: @disabled) disabled: name == :duration ? false : disabled, # duration is always editable label:, caption: caption(name), - data: { - test_selector: "op-datepicker-modal--#{name.to_s.dasherize}-field" - }, validation_message: validation_message(name) ) @@ -150,7 +147,8 @@ def validation_message(name) def default_field_options(name) data = { "work-packages--date-picker--preview-target": "fieldInput", - action: "work-packages--date-picker--preview#markFieldAsTouched" } + action: "work-packages--date-picker--preview#markFieldAsTouched", + test_selector: "op-datepicker-modal--#{name.to_s.dasherize}-field" } if @focused_field == name data[:focus] = "true" From 8486b4e2937339de0d62e079264f9873b9f0eb11 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 20 Jan 2025 10:04:33 +0100 Subject: [PATCH 43/92] Define the currently active field correctly and respect that when selecting a date from the datepicker --- .../form_content_component.html.erb | 4 +- .../work_packages/date_picker_controller.rb | 12 +++++- .../work_packages/date_picker/date_form.rb | 7 +++- .../basic-range-date-picker.component.ts | 1 - .../basic-single-date-picker.component.ts | 1 - .../modal-single-date-picker.component.ts | 2 +- .../sheet/date-picker-sheet.component.ts | 1 - .../wp-modal-date-picker.component.ts | 39 ++++++++++++------- .../global_styles/content/_work_packages.sass | 2 + .../work_packages/_datepicker_modal.sass} | 9 +---- .../date-picker/preview.controller.ts | 15 +++++++ 11 files changed, 64 insertions(+), 29 deletions(-) rename frontend/src/{app/shared/components/datepicker/styles/datepicker.modal.sass => global_styles/content/work_packages/_datepicker_modal.sass} (64%) diff --git a/app/components/work_packages/date_picker/form_content_component.html.erb b/app/components/work_packages/date_picker/form_content_component.html.erb index 581fc7917d91..68eaea348249 100644 --- a/app/components/work_packages/date_picker/form_content_component.html.erb +++ b/app/components/work_packages/date_picker/form_content_component.html.erb @@ -63,9 +63,9 @@ schedule_manually:, is_schedulable: !disabled?, is_milestone: work_package.is_milestone?, - field_name: focused_field, start_date_field_id: "work_package_start_date", - due_date_field_id: "work_package_due_date" + due_date_field_id: "work_package_due_date", + duration_field_id: "work_package_duration" } end else diff --git a/app/controllers/work_packages/date_picker_controller.rb b/app/controllers/work_packages/date_picker_controller.rb index a4ae482de196..55063f102b66 100644 --- a/app/controllers/work_packages/date_picker_controller.rb +++ b/app/controllers/work_packages/date_picker_controller.rb @@ -149,7 +149,17 @@ def datepicker_modal_component end def focused_field - params[:field] + trigger = params[:field] + + # Decide which field to focus next + case trigger + when "work_package[start_date]" + :due_date + when "work_package[duration]" + :duration + else + :start_date + end end def find_work_package diff --git a/app/forms/work_packages/date_picker/date_form.rb b/app/forms/work_packages/date_picker/date_form.rb index cda61c280811..af5a8a72bb98 100644 --- a/app/forms/work_packages/date_picker/date_form.rb +++ b/app/forms/work_packages/date_picker/date_form.rb @@ -101,6 +101,7 @@ def text_field(group, name:, label:, disabled: @disabled) disabled: name == :duration ? false : disabled, # duration is always editable label:, caption: caption(name), + classes: "op-datepicker-modal--date-field #{'op-datepicker-modal--date-field_current' if @focused_field == name}", validation_message: validation_message(name) ) @@ -147,9 +148,13 @@ def validation_message(name) def default_field_options(name) data = { "work-packages--date-picker--preview-target": "fieldInput", - action: "work-packages--date-picker--preview#markFieldAsTouched", + action: "work-packages--date-picker--preview#markFieldAsTouched focus->work-packages--date-picker--preview#highlightField", test_selector: "op-datepicker-modal--#{name.to_s.dasherize}-field" } + if @focused_field == name + data[:qa_highlighted] = "true" + end + if @focused_field == name data[:focus] = "true" end diff --git a/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.ts b/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.ts index 5f2e7e6a0983..b57cea6b821f 100644 --- a/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.ts +++ b/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.ts @@ -67,7 +67,6 @@ export const opBasicRangeDatePickerSelector = 'op-basic-range-date-picker'; selector: opBasicRangeDatePickerSelector, templateUrl: './basic-range-date-picker.component.html', styleUrls: [ - '../styles/datepicker.modal.sass', './basic-range-date-picker.component.sass', ], changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/frontend/src/app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component.ts b/frontend/src/app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component.ts index b73dd1a714bb..34e7f9e1b54d 100644 --- a/frontend/src/app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component.ts +++ b/frontend/src/app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component.ts @@ -55,7 +55,6 @@ import { DeviceService } from 'core-app/core/browser/device.service'; @Component({ selector: 'op-basic-single-date-picker', templateUrl: './basic-single-date-picker.component.html', - styleUrls: ['../styles/datepicker.modal.sass'], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, providers: [ diff --git a/frontend/src/app/shared/components/datepicker/modal-single-date-picker/modal-single-date-picker.component.ts b/frontend/src/app/shared/components/datepicker/modal-single-date-picker/modal-single-date-picker.component.ts index 6bb69b0dec8e..9344ccafa040 100644 --- a/frontend/src/app/shared/components/datepicker/modal-single-date-picker/modal-single-date-picker.component.ts +++ b/frontend/src/app/shared/components/datepicker/modal-single-date-picker/modal-single-date-picker.component.ts @@ -57,7 +57,7 @@ import { @Component({ selector: 'op-modal-single-date-picker', templateUrl: './modal-single-date-picker.component.html', - styleUrls: ['../styles/datepicker.modal.sass', './modal-single-date-picker.component.sass'], + styleUrls: ['./modal-single-date-picker.component.sass'], encapsulation: ViewEncapsulation.None, providers: [ { diff --git a/frontend/src/app/shared/components/datepicker/sheet/date-picker-sheet.component.ts b/frontend/src/app/shared/components/datepicker/sheet/date-picker-sheet.component.ts index 3abc657a691e..5685e5fc3fde 100644 --- a/frontend/src/app/shared/components/datepicker/sheet/date-picker-sheet.component.ts +++ b/frontend/src/app/shared/components/datepicker/sheet/date-picker-sheet.component.ts @@ -54,7 +54,6 @@ import { DeviceService } from 'core-app/core/browser/device.service'; selector: 'op-datepicker-sheet', templateUrl: './date-picker-sheet.component.html', styleUrls: [ - '../styles/datepicker.modal.sass', './date-picker-sheet.component.sass', ], changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts b/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts index 13f5db94c867..30e90e2b2b5e 100644 --- a/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts +++ b/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts @@ -59,9 +59,6 @@ import { filter } from 'rxjs/operators'; hidden> `, changeDetection: ChangeDetectionStrategy.OnPush, - styleUrls: [ - '../styles/datepicker.modal.sass', - ], }) export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements AfterViewInit { @Input() public ignoreNonWorkingDays:boolean; @@ -73,14 +70,16 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements @Input() public isSchedulable:boolean = true; @Input() public minimalSchedulingDate:Date|null; - @Input() fieldName:'start_date'|'due_date' = 'start_date'; @Input() startDateFieldId:string; @Input() dueDateFieldId:string; + @Input() durationFieldId:string; @Input() isMilestone:boolean = false; @ViewChild('flatpickrTarget') flatpickrTarget:ElementRef; + fieldName:'start_date'|'due_date'|'duration' = 'start_date'; + private datePickerInstance:DatePicker; constructor( @@ -149,20 +148,22 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements this.ensureHoveredSelection(instance.calendarContainer); }, onChange: (dates:Date[], _datestr, instance) => { + this.fieldName = this.getActiveField(); + if (this.isMilestone) { this.startDate = dates[0]; - this.setDateFieldAndFocus(this.startDate, this.startDateFieldId, null); + this.updateDateField(this.startDate, this.startDateFieldId); instance.setDate(this.startDate); } else if (this.fieldName === 'due_date') { this.dueDate = dates[0]; - this.setDateFieldAndFocus(this.dueDate, this.dueDateFieldId, this.startDateFieldId); + this.updateDateField(this.dueDate, this.dueDateFieldId); this.fieldName = 'start_date'; instance.setDate([this.startDate, this.dueDate]); } else { this.startDate = dates[0]; - this.setDateFieldAndFocus(this.startDate, this.startDateFieldId, this.dueDateFieldId); + this.updateDateField(this.startDate, this.startDateFieldId); this.fieldName = 'due_date'; instance.setDate([this.startDate, this.dueDate]); @@ -211,16 +212,28 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements .subscribe(() => calendarContainer.classList.add('flatpickr-container-suppress-hover')); } - private setDateFieldAndFocus(date:Date, fieldId:string | null, nextFieldId:string | null):void { + private getActiveField():'start_date'|'due_date'|'duration' { + const activeField = document.getElementsByClassName('op-datepicker-modal--date-field_current')[0]; + + if (!activeField) { + return this.fieldName; + } + + switch (activeField.id) { + case this.dueDateFieldId: + return 'due_date'; + case this.durationFieldId: + return 'duration'; + default: + return 'start_date'; + } + } + + private updateDateField(date:Date, fieldId:string | null):void { if (fieldId) { const field = document.getElementById(fieldId) as HTMLInputElement; field.value = this.timezoneService.formattedISODate(date); field.dispatchEvent(new Event('input')); } - - // Toggle focus to the next field - if (nextFieldId) { - document.getElementById(nextFieldId)?.focus(); - } } } 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/app/shared/components/datepicker/styles/datepicker.modal.sass b/frontend/src/global_styles/content/work_packages/_datepicker_modal.sass similarity index 64% rename from frontend/src/app/shared/components/datepicker/styles/datepicker.modal.sass rename to frontend/src/global_styles/content/work_packages/_datepicker_modal.sass index bf33092898c9..f02149124f6e 100644 --- a/frontend/src/app/shared/components/datepicker/styles/datepicker.modal.sass +++ b/frontend/src/global_styles/content/work_packages/_datepicker_modal.sass @@ -1,5 +1,3 @@ -@import '../../app/spot/styles/sass/variables' -@import '../../global_styles/openproject/variables' .op-datepicker-modal display: flex flex-direction: column @@ -16,12 +14,7 @@ &--date-field &_current, &_current:hover - // We want this to feel like the focus outline, but we cannot make it an actual outline - // because that would overwrite the focus outline when the input field is focused. - // So we make a border 2px wide like the outline, and then reduce margins by 1px so the - // size of the element does not change. - border: 2px solid var(--control-checked-color) - margin: -1px + border: 2px solid var(--control-checked-color) !important &--flatpickr-instance.inline margin: 0.5rem auto 0 auto !important 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 index b1ec8e6122ae..cb9c5a18a910 100644 --- 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 @@ -81,6 +81,21 @@ export default class PreviewController extends DialogPreviewController { 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 HTMLElement).classList.add('op-datepicker-modal--date-field_current'); + (newHighlightedField as HTMLElement).dataset.qaHighlighted = 'true'; + } + } + setTodayForField(event:unknown) { (event as Event).preventDefault(); From 9e504db4b0fe10763b4246889ac7d677a06263c8 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 20 Jan 2025 12:10:02 +0100 Subject: [PATCH 44/92] Get tests running by waiting for the controller to be loaded before starting to change things. Further some selectors have been fixed --- .../dialog_content_component.html.erb | 4 +++- .../components/datepicker/datepicker.rb | 5 +++++ .../datepicker/work_package_datepicker.rb | 21 ++++++------------- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/app/components/work_packages/date_picker/dialog_content_component.html.erb b/app/components/work_packages/date_picker/dialog_content_component.html.erb index 07abe2992cd2..a847b3656ad7 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.html.erb +++ b/app/components/work_packages/date_picker/dialog_content_component.html.erb @@ -55,13 +55,15 @@ collection.with_component(Primer::Alpha::Dialog::Footer.new) do component_collection do |footer| - footer.with_component(Primer::ButtonComponent.new(data: { action: "work-packages--date-picker--preview#cancel" })) do + footer.with_component(Primer::ButtonComponent.new(data: { action: "work-packages--date-picker--preview#cancel" }, + test_selector: "op-datepicker-modal--action")) do I18n.t("button_cancel") end footer.with_component(Primer::ButtonComponent.new(scheme: :primary, type: :submit, form: DIALOG_FORM_ID, + test_selector: "op-datepicker-modal--action", disabled: !schedulable?)) do I18n.t("button_save") end diff --git a/spec/support/components/datepicker/datepicker.rb b/spec/support/components/datepicker/datepicker.rb index 01ef8d1f311b..73de18bbcc5e 100644 --- a/spec/support/components/datepicker/datepicker.rb +++ b/spec/support/components/datepicker/datepicker.rb @@ -45,10 +45,15 @@ def clear! 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 b54d31319c59..f04c1596244c 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("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 @@ -147,10 +142,6 @@ def scheduling_mode_input container.find_field "scheduling", visible: :all end - def ignore_non_working_days_input - container.find_field "weekdays_only", visible: :all - end - def expect_ignore_non_working_days_disabled expect(container).to have_field("weekdays_only", disabled: true) end From 7924acc401d41069a85265c39e197c3f8744342d Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 20 Jan 2025 15:14:04 +0100 Subject: [PATCH 45/92] Disable duration and NonWorkingDays as specified --- .../date_picker/form_content_component.html.erb | 2 +- .../date_picker/form_content_component.rb | 4 ++++ app/forms/work_packages/date_picker/date_form.rb | 16 ++++++++++++++-- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/app/components/work_packages/date_picker/form_content_component.html.erb b/app/components/work_packages/date_picker/form_content_component.html.erb index 68eaea348249..357c0e59fe67 100644 --- a/app/components/work_packages/date_picker/form_content_component.html.erb +++ b/app/components/work_packages/date_picker/form_content_component.html.erb @@ -35,7 +35,7 @@ render(Primer::Alpha::CheckBox.new(name: "work_package[ignore_non_working_days]", label: I18n.t("work_packages.datepicker_modal.ignore_non_working_days.title"), checked: !work_package.ignore_non_working_days, - disabled: disabled?, + disabled: disabled_checkbox?, value: 0, unchecked_value: 1, data: { "work-packages--date-picker--preview-target": "fieldInput", diff --git a/app/components/work_packages/date_picker/form_content_component.rb b/app/components/work_packages/date_picker/form_content_component.rb index 52fe41610b23..c6a13565e85f 100644 --- a/app/components/work_packages/date_picker/form_content_component.rb +++ b/app/components/work_packages/date_picker/form_content_component.rb @@ -69,6 +69,10 @@ def disabled? !schedule_manually end + def disabled_checkbox? + !schedule_manually && work_package.children.any? + end + def parse_focused_field(focused_field) %i[start_date due_date duration].include?(focused_field) ? focused_field : :start_date end diff --git a/app/forms/work_packages/date_picker/date_form.rb b/app/forms/work_packages/date_picker/date_form.rb index af5a8a72bb98..ce6f80fed208 100644 --- a/app/forms/work_packages/date_picker/date_form.rb +++ b/app/forms/work_packages/date_picker/date_form.rb @@ -94,11 +94,11 @@ def initialize(work_package:, private - def text_field(group, name:, label:, disabled: @disabled) + def text_field(group, name:, label:) text_field_options = default_field_options(name).merge( name:, value: field_value(name), - disabled: name == :duration ? false : disabled, # duration is always editable + disabled: disabled?(name), label:, caption: caption(name), classes: "op-datepicker-modal--date-field #{'op-datepicker-modal--date-field_current' if @focused_field == name}", @@ -131,6 +131,18 @@ def touched(name) @touched_field_map["#{name}_touched"] || false end + def disabled?(name) + if name == :duration + if !@schedule_manually && @work_package.children.any? + return true + end + + return false + end + + @disabled + end + def field_value(name) errors = @work_package.errors.where(name) if (user_value = errors.map { |error| error.options[:value] }.find { !_1.nil? }) From 5bca247cca9058ea9485f39a409b01fc0cfb02bf Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 20 Jan 2025 15:15:38 +0100 Subject: [PATCH 46/92] Adapt some more test selectors --- .../form_content_component.html.erb | 15 ++++++-- .../wp-modal-date-picker.component.ts | 4 ++- .../datepicker/datepicker_logic_spec.rb | 6 ++-- .../datepicker/work_package_datepicker.rb | 36 +++++++++---------- 4 files changed, 34 insertions(+), 27 deletions(-) diff --git a/app/components/work_packages/date_picker/form_content_component.html.erb b/app/components/work_packages/date_picker/form_content_component.html.erb index 357c0e59fe67..807f1675eaf5 100644 --- a/app/components/work_packages/date_picker/form_content_component.html.erb +++ b/app/components/work_packages/date_picker/form_content_component.html.erb @@ -12,16 +12,24 @@ first_row.with_column do render(Primer::Alpha::FormControl.new(label: I18n.t("work_packages.datepicker_modal.mode.title"))) do |component| component.with_input do - render(Primer::Alpha::SegmentedControl.new("aria-label": I18n.t("work_packages.datepicker_modal.mode.title"))) do |control| + render(Primer::Alpha::SegmentedControl.new("aria-label": I18n.t("work_packages.datepicker_modal.mode.title"), test_selector: "op-datepicker-modal--scheduling",)) do |control| control.with_item(tag: :a, href: work_package_datepicker_dialog_content_path(params.merge(schedule_manually: true).permit!), - data: { turbo_stream: true }, + data: { + turbo_stream: true, + qa_selected: schedule_manually + }, + test_selector: "op-datepicker-modal--scheduling_manual", label: I18n.t("work_packages.datepicker_modal.mode.manual"), title: I18n.t("work_packages.datepicker_modal.mode.manual"), selected: schedule_manually) control.with_item(tag: :a, href: work_package_datepicker_dialog_content_path(params.merge(schedule_manually: false).permit!), - data: { turbo_stream: true }, + data: { + turbo_stream: true, + qa_selected: !schedule_manually + }, + test_selector: "op-datepicker-modal--scheduling_automatic", disabled: work_package.new_record?, label: I18n.t("work_packages.datepicker_modal.mode.automatic"), title: I18n.t("work_packages.datepicker_modal.mode.automatic"), @@ -36,6 +44,7 @@ label: I18n.t("work_packages.datepicker_modal.ignore_non_working_days.title"), checked: !work_package.ignore_non_working_days, disabled: disabled_checkbox?, + test_selector: "op-datepicker-modal--ignore-non-working-days", value: 0, unchecked_value: 1, data: { "work-packages--date-picker--preview-target": "fieldInput", diff --git a/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts b/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts index 30e90e2b2b5e..45de03ba8be2 100644 --- a/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts +++ b/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts @@ -134,6 +134,8 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements private initializeDatepicker() { this.datePickerInstance?.destroy(); + const ignoreNonWorkingDaysTemp = this.ignoreNonWorkingDays; + this.datePickerInstance = new DatePicker( this.injector, '#flatpickr-input', @@ -173,7 +175,7 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements onDayCreate: async (dObj:Date[], dStr:string, fp:flatpickr.Instance, dayElem:DayElement) => { onDayCreate( dayElem, - this.ignoreNonWorkingDays, + ignoreNonWorkingDaysTemp, await this.datePickerInstance?.isNonWorkingDay(dayElem.dateObj), this.isDayDisabled(dayElem), ); diff --git a/spec/features/work_packages/datepicker/datepicker_logic_spec.rb b/spec/features/work_packages/datepicker/datepicker_logic_spec.rb index f35f44a3c2fd..60e667c7405e 100644 --- a/spec/features/work_packages/datepicker/datepicker_logic_spec.rb +++ b/spec/features/work_packages/datepicker/datepicker_logic_spec.rb @@ -244,7 +244,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 +266,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 +279,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/support/components/datepicker/work_package_datepicker.rb b/spec/support/components/datepicker/work_package_datepicker.rb index f04c1596244c..f85058475996 100644 --- a/spec/support/components/datepicker/work_package_datepicker.rb +++ b/spec/support/components/datepicker/work_package_datepicker.rb @@ -86,7 +86,7 @@ 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 @@ -127,34 +127,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 + within_test_selector "op-datepicker-modal--scheduling" do + find_css('[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 @@ -165,13 +169,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 From 776c6503cd0a0279e77bcad5011f86e34f9213ee Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Mon, 20 Jan 2025 17:31:23 +0100 Subject: [PATCH 47/92] Do not display 'Today' link for duration --- .../work_packages/date_picker/date_form.rb | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/app/forms/work_packages/date_picker/date_form.rb b/app/forms/work_packages/date_picker/date_form.rb index ce6f80fed208..fc61ca598956 100644 --- a/app/forms/work_packages/date_picker/date_form.rb +++ b/app/forms/work_packages/date_picker/date_form.rb @@ -108,15 +108,18 @@ def text_field(group, name:, label:) group.text_field(**text_field_options) end - def caption(field) + def caption(name) + return if duration_field?(name) + text = I18n.t(:label_today).capitalize return text if @disabled - render(Primer::Beta::Link.new(href: "", data: { - action: "click->work-packages--date-picker--preview#setTodayForField", - "work-packages--date-picker--preview-field-reference-param": "work_package_#{field}", - test_selector: "op-datepicker-modal--#{field.to_s.dasherize}-field--today" + render(Primer::Beta::Link.new(href: "", + data: { + action: "work-packages--date-picker--preview#setTodayForField", + "work-packages--date-picker--preview-field-reference-param": "work_package_#{name}", + test_selector: "op-datepicker-modal--#{name.to_s.dasherize}-field--today" })) { text } end @@ -131,6 +134,10 @@ def touched(name) @touched_field_map["#{name}_touched"] || false end + def duration_field?(name) + name == :duration + end + def disabled?(name) if name == :duration if !@schedule_manually && @work_package.children.any? From 9312e52131387afc372c28b6ad320e1860d26624 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Mon, 20 Jan 2025 17:35:52 +0100 Subject: [PATCH 48/92] Fix rubocop complains --- app/forms/work_packages/date_picker/date_form.rb | 3 ++- app/forms/work_packages/date_picker/initial_values_form.rb | 2 ++ .../features/work_packages/datepicker/datepicker_logic_spec.rb | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/forms/work_packages/date_picker/date_form.rb b/app/forms/work_packages/date_picker/date_form.rb index fc61ca598956..d90b820b10ef 100644 --- a/app/forms/work_packages/date_picker/date_form.rb +++ b/app/forms/work_packages/date_picker/date_form.rb @@ -167,7 +167,8 @@ def validation_message(name) def default_field_options(name) data = { "work-packages--date-picker--preview-target": "fieldInput", - action: "work-packages--date-picker--preview#markFieldAsTouched focus->work-packages--date-picker--preview#highlightField", + action: "work-packages--date-picker--preview#markFieldAsTouched " \ + "focus->work-packages--date-picker--preview#highlightField", test_selector: "op-datepicker-modal--#{name.to_s.dasherize}-field" } if @focused_field == name diff --git a/app/forms/work_packages/date_picker/initial_values_form.rb b/app/forms/work_packages/date_picker/initial_values_form.rb index 4dd40a33dd57..2c6d613005b3 100644 --- a/app/forms/work_packages/date_picker/initial_values_form.rb +++ b/app/forms/work_packages/date_picker/initial_values_form.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # -- copyright # OpenProject is an open source project management software. # Copyright (C) 2010-2024 the OpenProject GmbH diff --git a/spec/features/work_packages/datepicker/datepicker_logic_spec.rb b/spec/features/work_packages/datepicker/datepicker_logic_spec.rb index 60e667c7405e..00ae0a9b1f32 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 From 51b3eba7bf02c588a3e49069568e454a6a562d92 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 21 Jan 2025 09:02:00 +0100 Subject: [PATCH 49/92] Ensure reinitialization of datepicker on change events is done only once --- .../wp-modal-date-picker.component.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts b/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts index 45de03ba8be2..f63377e6e2df 100644 --- a/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts +++ b/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts @@ -47,8 +47,8 @@ import { DatePicker } from '../datepicker'; import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; import { populateInputsFromDataset } from 'core-app/shared/components/dataset-inputs'; -import { fromEvent } from 'rxjs'; -import { filter } from 'rxjs/operators'; +import { fromEvent, Subject } from 'rxjs'; +import { debounceTime, filter } from 'rxjs/operators'; @Component({ selector: 'op-wp-modal-date-picker', @@ -81,6 +81,7 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements fieldName:'start_date'|'due_date'|'duration' = 'start_date'; private datePickerInstance:DatePicker; + private initializeDatepickerSubject = new Subject(); constructor( readonly injector:Injector, @@ -94,10 +95,15 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements ) { super(); populateInputsFromDataset(this); + + // To make sure the datepicker is reinitialized only once when multiple change events are received + this.initializeDatepickerSubject.pipe( + debounceTime(0), + ).subscribe(() => this.initializeDatepicker()); } ngAfterViewInit():void { - this.initializeDatepicker(); + this.initializeDatepickerSubject.next(); document.addEventListener('date-picker:input-changed', this.changeListener.bind(this)); } @@ -127,9 +133,8 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements return; } - window.setTimeout(() => { - this.initializeDatepicker(); - }); + // Emit an event to the subject, which will be debounced and trigger the datepicker initialization + this.initializeDatepickerSubject.next(); } private initializeDatepicker() { From 5f605205bb09684d608c195d0c424e8f57e119d5 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Tue, 21 Jan 2025 10:18:54 +0100 Subject: [PATCH 50/92] Fix some more tests --- .../work_packages/date_picker/banner_component.rb | 11 ++++++++++- spec/support/components/datepicker/datepicker.rb | 4 ++-- .../components/datepicker/work_package_datepicker.rb | 8 +++----- spec/support/edit_fields/date_edit_field.rb | 4 ++-- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/app/components/work_packages/date_picker/banner_component.rb b/app/components/work_packages/date_picker/banner_component.rb index a2305a4e14aa..c093290ed159 100644 --- a/app/components/work_packages/date_picker/banner_component.rb +++ b/app/components/work_packages/date_picker/banner_component.rb @@ -138,11 +138,20 @@ def all_relational_wp_ids .uniq end + def test_selector + if scheme == :warning + "op-modal-banner-warning" + else + "op-modal-banner-info" + end + end + def banner_options { scheme:, full: true, - icon: :info + icon: :info, + test_selector: } end end diff --git a/spec/support/components/datepicker/datepicker.rb b/spec/support/components/datepicker/datepicker.rb index 73de18bbcc5e..a8e46495d799 100644 --- a/spec/support/components/datepicker/datepicker.rb +++ b/spec/support/components/datepicker/datepicker.rb @@ -39,8 +39,8 @@ 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 diff --git a/spec/support/components/datepicker/work_package_datepicker.rb b/spec/support/components/datepicker/work_package_datepicker.rb index f85058475996..2364151e7281 100644 --- a/spec/support/components/datepicker/work_package_datepicker.rb +++ b/spec/support/components/datepicker/work_package_datepicker.rb @@ -98,9 +98,7 @@ def focus_duration end def set_today(date) - page.within("[data-test-selector='datepicker-#{date}-date-field--today']") do - find("a", text: "Today").click - end + page.find_test_selector("op-datepicker-modal--#{date}-date-field--today").click end def save!(text: I18n.t(:button_save)) @@ -137,8 +135,8 @@ def expect_automatic_scheduling_mode end def toggle_scheduling_mode - within_test_selector "op-datepicker-modal--scheduling" do - find_css('[data-qa-selected]="false"').click + page.within_test_selector "op-datepicker-modal--scheduling" do + page.find('[data-qa-selected="false"]').click end end diff --git a/spec/support/edit_fields/date_edit_field.rb b/spec/support/edit_fields/date_edit_field.rb index 0fb84dfa5b36..68aecb8df074 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 From 24c1bd67384539a4141da3da1ef42a1780fcd9a3 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 21 Jan 2025 12:36:11 +0100 Subject: [PATCH 51/92] Fix progress popover features tests Debounce value was changed in the app part, but was not updated in the test. Also, changed the way the field is focused to avoid the select to be opened (because it could stay opened sometimes). And for input fields being focused, it required to also move the carte to the end of the input before clearing it. It was not needed before because we used to click in the field to give focus, and clicking also moves the caret to the end (most of the time). --- .../work-packages/dialog/preview.controller.ts | 2 ++ spec/support/edit_fields/progress_edit_field.rb | 12 +++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) 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 index 93c6368dc314..707f96186351 100644 --- a/frontend/src/stimulus/controllers/dynamic/work-packages/dialog/preview.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/work-packages/dialog/preview.controller.ts @@ -70,6 +70,8 @@ export abstract class DialogPreviewController extends Controller { } }); + // 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 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 From 792a839f967ab84cd24a32f384041e710fdc48e4 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 21 Jan 2025 12:39:32 +0100 Subject: [PATCH 52/92] Fix date conversion when date is not set --- .../wp-modal-date-picker.component.ts | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts b/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts index f63377e6e2df..d842243f1913 100644 --- a/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts +++ b/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts @@ -64,8 +64,8 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements @Input() public ignoreNonWorkingDays:boolean; @Input() public scheduleManually:boolean; - @Input() public startDate:Date; - @Input() public dueDate:Date; + @Input() public startDate:Date|null; + @Input() public dueDate:Date|null; @Input() public isSchedulable:boolean = true; @Input() public minimalSchedulingDate:Date|null; @@ -120,10 +120,10 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements switch (details.field) { case 'work_package[start_date]': - this.startDate = new Date(details.value); + this.startDate = this.toDate(details.value); break; case 'work_package[due_date]': - this.dueDate = new Date(details.value); + this.dueDate = this.toDate(details.value); break; case 'work_package[ignore_non_working_days]': this.ignoreNonWorkingDays = details.value !== 'true'; @@ -137,14 +137,28 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements this.initializeDatepickerSubject.next(); } + private toDate(date:string):Date|null { + if (date) { + return new Date(date); + } + return null; + } + private initializeDatepicker() { this.datePickerInstance?.destroy(); const ignoreNonWorkingDaysTemp = this.ignoreNonWorkingDays; + const initialDates = []; + if (this.startDate) { + initialDates.push(this.startDate); + } + if (this.dueDate && !this.isMilestone) { + initialDates.push(this.dueDate); + } this.datePickerInstance = new DatePicker( this.injector, '#flatpickr-input', - this.isMilestone ? this.startDate : [this.startDate || '', this.dueDate || ''], + initialDates, { mode: this.isMilestone ? 'single' : 'range', showMonths: this.deviceService.isMobile ? 1 : 2, @@ -161,19 +175,19 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements this.startDate = dates[0]; this.updateDateField(this.startDate, this.startDateFieldId); - instance.setDate(this.startDate); + instance.setDate(this.startDate || ''); } else if (this.fieldName === 'due_date') { this.dueDate = dates[0]; this.updateDateField(this.dueDate, this.dueDateFieldId); this.fieldName = 'start_date'; - instance.setDate([this.startDate, this.dueDate]); + instance.setDate([this.startDate || '', this.dueDate || '']); } else { this.startDate = dates[0]; this.updateDateField(this.startDate, this.startDateFieldId); this.fieldName = 'due_date'; - instance.setDate([this.startDate, this.dueDate]); + instance.setDate([this.startDate || '', this.dueDate || '']); } }, // eslint-disable-next-line @typescript-eslint/no-misused-promises From a20aa869977cf5d8e4cb479e6448adaacfde4062 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Tue, 21 Jan 2025 13:58:58 +0100 Subject: [PATCH 53/92] Take care that creation of Milestones is working correctly --- .../date_picker/form_content_component.html.erb | 3 ++- .../date_picker/form_content_component.rb | 7 +++++++ app/forms/work_packages/date_picker/date_form.rb | 5 +++-- .../work_packages/date_picker/initial_values_form.rb | 10 +++++++--- .../field-types/combined-date-edit-field.component.ts | 4 +++- 5 files changed, 22 insertions(+), 7 deletions(-) diff --git a/app/components/work_packages/date_picker/form_content_component.html.erb b/app/components/work_packages/date_picker/form_content_component.html.erb index 807f1675eaf5..c161167bb296 100644 --- a/app/components/work_packages/date_picker/form_content_component.html.erb +++ b/app/components/work_packages/date_picker/form_content_component.html.erb @@ -58,6 +58,7 @@ render(WorkPackages::DatePicker::DateForm.new(f, work_package:, schedule_manually:, + is_milestone: milestone?, focused_field:, touched_field_map:, disabled: disabled?)) @@ -71,7 +72,7 @@ ignore_non_working_days: work_package.ignore_non_working_days, schedule_manually:, is_schedulable: !disabled?, - is_milestone: work_package.is_milestone?, + is_milestone: milestone?, start_date_field_id: "work_package_start_date", due_date_field_id: "work_package_due_date", duration_field_id: "work_package_duration" diff --git a/app/components/work_packages/date_picker/form_content_component.rb b/app/components/work_packages/date_picker/form_content_component.rb index c6a13565e85f..d24c1c12e1de 100644 --- a/app/components/work_packages/date_picker/form_content_component.rb +++ b/app/components/work_packages/date_picker/form_content_component.rb @@ -69,6 +69,13 @@ def disabled? !schedule_manually end + def milestone? + # Either the work package is a milestone OR in the create form, the angular 'date' field was triggered OR + # in the WorkPackage create form, the datepicker dialog was already updated via Turbo + # in which case the field param is overwritten and we have to check whether there is a due date field + @milestone ||= @work_package.milestone? || params[:field] == "date" || params[:work_package][:due_date].nil? + end + def disabled_checkbox? !schedule_manually && work_package.children.any? end diff --git a/app/forms/work_packages/date_picker/date_form.rb b/app/forms/work_packages/date_picker/date_form.rb index d90b820b10ef..4ce297c9853d 100644 --- a/app/forms/work_packages/date_picker/date_form.rb +++ b/app/forms/work_packages/date_picker/date_form.rb @@ -53,13 +53,14 @@ def before_render def initialize(work_package:, schedule_manually:, disabled:, + is_milestone:, focused_field: :start_date, touched_field_map: {}) super() @work_package = work_package @schedule_manually = schedule_manually - @is_milestone = work_package.milestone? + @is_milestone = is_milestone @focused_field = focused_field @touched_field_map = touched_field_map @disabled = disabled @@ -87,7 +88,7 @@ def initialize(work_package:, hidden_touched_field(group, name: :schedule_manually) group.fields_for(:initial) do |builder| - WorkPackages::DatePicker::InitialValuesForm.new(builder, work_package:) + WorkPackages::DatePicker::InitialValuesForm.new(builder, work_package:, is_milestone: @is_milestone) end end end diff --git a/app/forms/work_packages/date_picker/initial_values_form.rb b/app/forms/work_packages/date_picker/initial_values_form.rb index 2c6d613005b3..ef998812f86b 100644 --- a/app/forms/work_packages/date_picker/initial_values_form.rb +++ b/app/forms/work_packages/date_picker/initial_values_form.rb @@ -33,18 +33,22 @@ module DatePicker class InitialValuesForm < ApplicationForm attr_reader :work_package - def initialize(work_package:) + def initialize(work_package:, is_milestone:) super() @work_package = work_package + @is_milestone = is_milestone end form do |form| hidden_initial_field(form, name: :start_date) - hidden_initial_field(form, name: :due_date) - hidden_initial_field(form, name: :duration) hidden_initial_field(form, name: :ignore_non_working_days) hidden_initial_field(form, name: :schedule_manually) + + unless @is_milestone + hidden_initial_field(form, name: :due_date) + hidden_initial_field(form, name: :duration) + end end private diff --git a/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.ts index b97bf66c0490..a8f6ae71630f 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.ts @@ -112,7 +112,9 @@ export class CombinedDateEditFieldComponent extends DatePickerEditFieldComponent } protected current(dateAttribute:'startDate' | 'dueDate' | 'date'):string { - const value = (this.resource && (this.resource as WorkPackageResource)[dateAttribute]) as string|null; + // Since the rework of the datepicker, the milestone date field has the name 'start_date' to match the database + const valueReference = dateAttribute === 'date' ? 'startDate' : dateAttribute; + const value = (this.resource && (this.resource as WorkPackageResource)[valueReference]) as string|null; return (value || this.text.placeholder[dateAttribute]); } } From 7a1022b083b0253a97104f26169ba937b4f62e69 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 21 Jan 2025 14:53:46 +0100 Subject: [PATCH 54/92] Disable a test until we have a 'clear' button on duration field --- .../work_packages/datepicker/datepicker_logic_spec.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/features/work_packages/datepicker/datepicker_logic_spec.rb b/spec/features/work_packages/datepicker/datepicker_logic_spec.rb index 00ae0a9b1f32..5b8ba682e28a 100644 --- a/spec/features/work_packages/datepicker/datepicker_logic_spec.rb +++ b/spec/features/work_packages/datepicker/datepicker_logic_spec.rb @@ -232,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"), From 593495713aa9b18e4b9dc8832ee29b166d88d39a Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Tue, 21 Jan 2025 15:21:57 +0100 Subject: [PATCH 55/92] Show date form when the WorkPackage is in automatic scheduling mode and has children --- .../date_picker/dialog_content_component.html.erb | 6 +++--- .../work_packages/date_picker/dialog_content_component.rb | 7 +++++-- .../date_picker/form_content_component.html.erb | 2 +- .../work_packages/date_picker/form_content_component.rb | 6 +++--- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/app/components/work_packages/date_picker/dialog_content_component.html.erb b/app/components/work_packages/date_picker/dialog_content_component.html.erb index a847b3656ad7..d00a0effd790 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.html.erb +++ b/app/components/work_packages/date_picker/dialog_content_component.html.erb @@ -5,7 +5,7 @@ test_selector: "op-datepicker-modal" }, class: "wp-datepicker-dialog--content") do component_collection do |collection| - if schedulable? && has_relations? + if show_banner? collection.with_component(WorkPackages::DatePicker::BannerComponent.new(work_package:, manually_scheduled: schedule_manually)) end @@ -16,7 +16,7 @@ tab.with_text { I18n.t("work_packages.datepicker_modal.tabs.dates") } tab.with_panel do render(WorkPackages::DatePicker::FormContentComponent.new(form_id: DIALOG_FORM_ID, - schedulable: schedulable?, + show_date_form: content_editable?, work_package:, schedule_manually:, focused_field:, @@ -64,7 +64,7 @@ type: :submit, form: DIALOG_FORM_ID, test_selector: "op-datepicker-modal--action", - disabled: !schedulable?)) do + disabled: !content_editable?)) do I18n.t("button_save") end end diff --git a/app/components/work_packages/date_picker/dialog_content_component.rb b/app/components/work_packages/date_picker/dialog_content_component.rb index c1250092b875..bfed91963197 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.rb +++ b/app/components/work_packages/date_picker/dialog_content_component.rb @@ -79,10 +79,13 @@ def additional_tabs ] end - def schedulable? - @schedule_manually || follows_relations.any? + def content_editable? + @schedule_manually || follows_relations.any? || children.any? end + def show_banner? + @schedule_manually || has_relations? + end def has_relations? precedes_relations.any? || follows_relations.any? || children.any? end diff --git a/app/components/work_packages/date_picker/form_content_component.html.erb b/app/components/work_packages/date_picker/form_content_component.html.erb index c161167bb296..8289be4dbcca 100644 --- a/app/components/work_packages/date_picker/form_content_component.html.erb +++ b/app/components/work_packages/date_picker/form_content_component.html.erb @@ -53,7 +53,7 @@ end end - if schedulable + if show_date_form body.with_row(mb: 3, classes: "FormControl-horizontalGroup--sm-vertical") do render(WorkPackages::DatePicker::DateForm.new(f, work_package:, diff --git a/app/components/work_packages/date_picker/form_content_component.rb b/app/components/work_packages/date_picker/form_content_component.rb index d24c1c12e1de..7406e0216823 100644 --- a/app/components/work_packages/date_picker/form_content_component.rb +++ b/app/components/work_packages/date_picker/form_content_component.rb @@ -34,10 +34,10 @@ class FormContentComponent < ApplicationComponent include OpPrimer::ComponentHelpers include OpTurbo::Streamable - attr_accessor :form_id, :schedulable, :work_package, :schedule_manually, :focused_field, :touched_field_map + attr_accessor :form_id, :show_date_form, :work_package, :schedule_manually, :focused_field, :touched_field_map def initialize(form_id:, - schedulable:, + show_date_form:, work_package:, schedule_manually: true, focused_field: :start_date, @@ -45,7 +45,7 @@ def initialize(form_id:, super @form_id = form_id - @schedulable = schedulable + @show_date_form = show_date_form @work_package = work_package @schedule_manually = ActiveModel::Type::Boolean.new.cast(schedule_manually) @focused_field = parse_focused_field(focused_field) From 8a75a6849cfd7e54fb45ff4a823469abd8964794 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Tue, 21 Jan 2025 17:47:34 +0100 Subject: [PATCH 56/92] Improve datepicker behavior when choosing dates Make it so that selecting dates in the flatpicker calendar no longer displays "this date should be before/after the other". --- .../wp-modal-date-picker.component.ts | 129 +++++++++++++----- 1 file changed, 97 insertions(+), 32 deletions(-) diff --git a/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts b/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts index d842243f1913..23c638279264 100644 --- a/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts +++ b/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts @@ -49,6 +49,7 @@ import { PathHelperService } from 'core-app/core/path-helper/path-helper.service import { populateInputsFromDataset } from 'core-app/shared/components/dataset-inputs'; import { fromEvent, Subject } from 'rxjs'; import { debounceTime, filter } from 'rxjs/operators'; +import * as _ from 'lodash'; @Component({ selector: 'op-wp-modal-date-picker', @@ -144,21 +145,18 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements return null; } + private currentDates():Date[] { + return _.compact([this.startDate, this.dueDate]); + } + private initializeDatepicker() { this.datePickerInstance?.destroy(); const ignoreNonWorkingDaysTemp = this.ignoreNonWorkingDays; - const initialDates = []; - if (this.startDate) { - initialDates.push(this.startDate); - } - if (this.dueDate && !this.isMilestone) { - initialDates.push(this.dueDate); - } this.datePickerInstance = new DatePicker( this.injector, '#flatpickr-input', - initialDates, + this.currentDates(), { mode: this.isMilestone ? 'single' : 'range', showMonths: this.deviceService.isMobile ? 1 : 2, @@ -168,28 +166,7 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements this.ensureHoveredSelection(instance.calendarContainer); }, - onChange: (dates:Date[], _datestr, instance) => { - this.fieldName = this.getActiveField(); - - if (this.isMilestone) { - this.startDate = dates[0]; - this.updateDateField(this.startDate, this.startDateFieldId); - - instance.setDate(this.startDate || ''); - } else if (this.fieldName === 'due_date') { - this.dueDate = dates[0]; - this.updateDateField(this.dueDate, this.dueDateFieldId); - this.fieldName = 'start_date'; - - instance.setDate([this.startDate || '', this.dueDate || '']); - } else { - this.startDate = dates[0]; - this.updateDateField(this.startDate, this.startDateFieldId); - this.fieldName = 'due_date'; - - instance.setDate([this.startDate || '', this.dueDate || '']); - } - }, + onChange: this.onFlatpickrChange.bind(this), // eslint-disable-next-line @typescript-eslint/no-misused-promises onDayCreate: async (dObj:Date[], dStr:string, fp:flatpickr.Instance, dayElem:DayElement) => { onDayCreate( @@ -205,6 +182,90 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements ); } + private onFlatpickrChange(dates:Date[], _datestr:string, instance:flatpickr.Instance) { + this.fieldName = this.getActiveField(); + + if (this.isMilestone) { + this.setStartDate(dates[0]); + instance.setDate(dates[0]); + } else { + const selectedDate:Date = this.lastClickedDate(dates) || dates[0]; + if (this.fieldName === 'due_date') { + this.setDueDate(selectedDate); + this.fieldName = 'start_date'; + } else { + this.setStartDate(selectedDate); + this.fieldName = 'due_date'; + } + instance.setDate(this.currentDates()); + } + } + + private lastClickedDate(changedDates:Date[]):Date|null { + const flatPickrDates = changedDates.map((date) => this.timezoneService.formattedISODate(date)); + const fieldDates = _.compact([this.startDate, this.dueDate]) + .map((date) => this.timezoneService.formattedISODate(date)); + if (flatPickrDates.length === 1) { + return this.toDate(flatPickrDates[0]); + } + const diff = _.difference(flatPickrDates, fieldDates); + return this.toDate(diff[0]); + } + + // Sets the start date to the given date. + // + // If the given date is after the due date, then there are two cases: + // - if only one date is already set, then dates are swapped so that start + // date is before due date. + // - if both dates are already set, then the due date is cleared because it + // can't be before the start date. + private setStartDate(date:Date) { + if (this.dueDate && date > this.dueDate) { + if (this.startDate) { + // if both dates are set and the clicked date is after the due date, + // then the start date is set to the clicked date the due date is cleared + this.startDate = date; + this.dueDate = null; + } else { + // else one of the two dates is not set, so we are smart and swap them + this.startDate = this.dueDate; + this.dueDate = date; + } + this.updateDateField(this.dueDate, this.dueDateFieldId); + } else { + // simply set the start date + this.startDate = date; + } + this.updateDateField(this.startDate, this.startDateFieldId); + } + + // Sets the due date to the given date. + // + // If the given date is before the start date, then there are two cases: + // - if only one date is already set, then dates are swapped so that start + // date is before due date. + // - if both dates are already set, then the start date is cleared because + // it can't be after the due date. + private setDueDate(date:Date) { + if (this.startDate && this.startDate > date) { + if (this.dueDate) { + // if both dates are set and the clicked date is before the start date, + // then the due date is set to the clicked date the start date is cleared + this.startDate = null; + this.dueDate = date; + } else { + // else one of the two dates is not set, so we are smart and swap them + this.dueDate = this.startDate; + this.startDate = date; + } + this.updateDateField(this.startDate, this.startDateFieldId); + } else { + // simply set the due date + this.dueDate = date; + } + this.updateDateField(this.dueDate, this.dueDateFieldId); + } + private isDayDisabled(dayElement:DayElement):boolean { const minimalDate = this.minimalSchedulingDate || null; return !this.isSchedulable || (!this.scheduleManually && !!minimalDate && dayElement.dateObj <= minimalDate); @@ -250,10 +311,14 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements } } - private updateDateField(date:Date, fieldId:string | null):void { + private updateDateField(date:Date|null, fieldId:string | null):void { if (fieldId) { const field = document.getElementById(fieldId) as HTMLInputElement; - field.value = this.timezoneService.formattedISODate(date); + if (date) { + field.value = this.timezoneService.formattedISODate(date); + } else { + field.value = ''; + } field.dispatchEvent(new Event('input')); } } From c85240a5e5b97778b3e8f4131df0f75276efe6f7 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Wed, 22 Jan 2025 08:23:53 +0100 Subject: [PATCH 57/92] Add trailing unit for duration field --- app/forms/work_packages/date_picker/date_form.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/forms/work_packages/date_picker/date_form.rb b/app/forms/work_packages/date_picker/date_form.rb index 4ce297c9853d..cce05044646e 100644 --- a/app/forms/work_packages/date_picker/date_form.rb +++ b/app/forms/work_packages/date_picker/date_form.rb @@ -106,6 +106,12 @@ def text_field(group, name:, label:) validation_message: validation_message(name) ) + if name == :duration + text_field_options = text_field_options.merge( + trailing_visual: { text: { text: I18n.t("datetime.units.day.other") } } + ) + end + group.text_field(**text_field_options) end From 19d2671a76fb7c6e5e417de7bf76a2221f3cce06 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Wed, 22 Jan 2025 08:30:17 +0100 Subject: [PATCH 58/92] Add special logic for edge case in which only one date is given and the user wants to modify exactly that --- .../wp-modal-date-picker.component.ts | 32 ++++++++++++++++++- .../date-picker/preview.controller.ts | 12 +++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts b/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts index 23c638279264..3bec749ed470 100644 --- a/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts +++ b/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts @@ -107,6 +107,7 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements this.initializeDatepickerSubject.next(); document.addEventListener('date-picker:input-changed', this.changeListener.bind(this)); + document.addEventListener('date-picker:input-focused', this.focusListener.bind(this)); } // eslint-disable-next-line @angular-eslint/use-lifecycle-interface @@ -114,6 +115,7 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements super.ngOnDestroy(); document.removeEventListener('date-picker:input-changed', this.changeListener.bind(this)); + document.removeEventListener('date-picker:input-focused', this.focusListener.bind(this)); } changeListener(event:CustomEvent) { @@ -138,6 +140,17 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements this.initializeDatepickerSubject.next(); } + focusListener(event:CustomEvent) { + const details = (event.detail as { field:string }); + + if (`work_package[${this.fieldName}]` !== details.field) { + // In case a different field is focused, we re-initialize the datepicker to allow for example + // * disabling different dates + // * switching between single and range mode in certain edge case (see getter for mode below) + this.initializeDatepickerSubject.next(); + } + } + private toDate(date:string):Date|null { if (date) { return new Date(date); @@ -151,6 +164,7 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements private initializeDatepicker() { this.datePickerInstance?.destroy(); + this.fieldName = this.getActiveField(); const ignoreNonWorkingDaysTemp = this.ignoreNonWorkingDays; this.datePickerInstance = new DatePicker( @@ -158,7 +172,7 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements '#flatpickr-input', this.currentDates(), { - mode: this.isMilestone ? 'single' : 'range', + mode: this.mode, showMonths: this.deviceService.isMobile ? 1 : 2, inline: true, onReady: (_date, _datestr, instance) => { @@ -182,6 +196,22 @@ export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements ); } + private get mode():'single'|'range' { + if (this.isMilestone) { + return 'single'; + } + + // This is a very special case in which only one date is set, and we want to modify exactly that date again. + // Then it does not make sense to display a range as we are only changing one date + if (this.currentDates().length === 1) { + if ((this.startDate !== null && this.fieldName === 'start_date') || (this.dueDate !== null && this.fieldName === 'due_date')) { + return 'single'; + } + } + + return 'range'; + } + private onFlatpickrChange(dates:Date[], _datestr:string, instance:flatpickr.Instance) { this.fieldName = this.getActiveField(); 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 index cb9c5a18a910..9ca48678975f 100644 --- 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 @@ -91,8 +91,16 @@ export default class PreviewController extends DialogPreviewController { }, ); - (newHighlightedField as HTMLElement).classList.add('op-datepicker-modal--date-field_current'); - (newHighlightedField as HTMLElement).dataset.qaHighlighted = 'true'; + (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, + }, + }), + ); } } From 50694a486b0543dea5997191d216dfca1deebc05 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Wed, 22 Jan 2025 09:16:41 +0100 Subject: [PATCH 59/92] Switch the focused date field if one of the values is empty --- .../work_packages/date_picker/date_form.rb | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/app/forms/work_packages/date_picker/date_form.rb b/app/forms/work_packages/date_picker/date_form.rb index cce05044646e..e69e8846b97d 100644 --- a/app/forms/work_packages/date_picker/date_form.rb +++ b/app/forms/work_packages/date_picker/date_form.rb @@ -61,7 +61,7 @@ def initialize(work_package:, @work_package = work_package @schedule_manually = schedule_manually @is_milestone = is_milestone - @focused_field = focused_field + @focused_field = update_focused_field @touched_field_map = touched_field_map @disabled = disabled end @@ -106,7 +106,7 @@ def text_field(group, name:, label:) validation_message: validation_message(name) ) - if name == :duration + if duration_field?(name) text_field_options = text_field_options.merge( trailing_visual: { text: { text: I18n.t("datetime.units.day.other") } } ) @@ -145,6 +145,22 @@ def duration_field?(name) name == :duration end + def update_focused_field + return @focused_field if @focused_field == :duration + + if @focused_field.nil? + @focused_field = :start_date + end + + opposite_field = @focused_field == :start_date ? :due_date : :start_date + + if field_value(@focused_field).present? && field_value(opposite_field).nil? + opposite_field + else + @focused_field + end + end + def disabled?(name) if name == :duration if !@schedule_manually && @work_package.children.any? From f449e0de681af10264d60a525e896d6b2512cb62 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Thu, 23 Jan 2025 08:35:34 +0100 Subject: [PATCH 60/92] Only change the focus when the triggered field was the combinedField. Otherwise we respect the users choice --- .../date_picker/dialog_content_component.rb | 1 + .../date_picker/form_content_component.rb | 6 +---- .../work_packages/date_picker/date_form.rb | 27 ++++++++++--------- .../work_packages/date_picker/show.html.erb | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/app/components/work_packages/date_picker/dialog_content_component.rb b/app/components/work_packages/date_picker/dialog_content_component.rb index bfed91963197..a292e5fb286b 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.rb +++ b/app/components/work_packages/date_picker/dialog_content_component.rb @@ -86,6 +86,7 @@ def content_editable? def show_banner? @schedule_manually || has_relations? end + def has_relations? precedes_relations.any? || follows_relations.any? || children.any? end diff --git a/app/components/work_packages/date_picker/form_content_component.rb b/app/components/work_packages/date_picker/form_content_component.rb index 7406e0216823..331301830204 100644 --- a/app/components/work_packages/date_picker/form_content_component.rb +++ b/app/components/work_packages/date_picker/form_content_component.rb @@ -48,7 +48,7 @@ def initialize(form_id:, @show_date_form = show_date_form @work_package = work_package @schedule_manually = ActiveModel::Type::Boolean.new.cast(schedule_manually) - @focused_field = parse_focused_field(focused_field) + @focused_field = focused_field @touched_field_map = touched_field_map end @@ -79,10 +79,6 @@ def milestone? def disabled_checkbox? !schedule_manually && work_package.children.any? end - - def parse_focused_field(focused_field) - %i[start_date due_date duration].include?(focused_field) ? focused_field : :start_date - end end end end diff --git a/app/forms/work_packages/date_picker/date_form.rb b/app/forms/work_packages/date_picker/date_form.rb index e69e8846b97d..14a9a524ee43 100644 --- a/app/forms/work_packages/date_picker/date_form.rb +++ b/app/forms/work_packages/date_picker/date_form.rb @@ -61,7 +61,7 @@ def initialize(work_package:, @work_package = work_package @schedule_manually = schedule_manually @is_milestone = is_milestone - @focused_field = update_focused_field + @focused_field = update_focused_field(focused_field) @touched_field_map = touched_field_map @disabled = disabled end @@ -145,19 +145,22 @@ def duration_field?(name) name == :duration end - def update_focused_field - return @focused_field if @focused_field == :duration + def update_focused_field(focused_field) + return :start_date if focused_field.nil? - if @focused_field.nil? - @focused_field = :start_date - end - - opposite_field = @focused_field == :start_date ? :due_date : :start_date - - if field_value(@focused_field).present? && field_value(opposite_field).nil? - opposite_field + case focused_field.to_s.underscore + when "combined_date" + if field_value(:start_date).present? && field_value(:due_date).nil? + :due_date + else + :start_date + end + when "due_date" + :due_date + when "duration" + :duration else - @focused_field + :start_date end end diff --git a/app/views/work_packages/date_picker/show.html.erb b/app/views/work_packages/date_picker/show.html.erb index 9863110334b9..a8198c36dbe3 100644 --- a/app/views/work_packages/date_picker/show.html.erb +++ b/app/views/work_packages/date_picker/show.html.erb @@ -1 +1 @@ -<%= render(WorkPackages::DatePicker::DialogContentComponent.new(work_package:, schedule_manually:))%> +<%= render(WorkPackages::DatePicker::DialogContentComponent.new(work_package:, schedule_manually:, focused_field: params[:field]))%> From 3ab4311585a3a22dcca0c76f2a20c5363c3cbdb1 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Thu, 23 Jan 2025 10:22:47 +0100 Subject: [PATCH 61/92] Move test to cuprite in order to wait for the idle network --- spec/features/work_packages/details/date_editor_spec.rb | 3 ++- spec/support/edit_fields/date_edit_field.rb | 6 ++++-- spec/support/edit_fields/edit_field.rb | 1 + 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/spec/features/work_packages/details/date_editor_spec.rb b/spec/features/work_packages/details/date_editor_spec.rb index ca273c164bf4..0d75cdaf5e0b 100644 --- a/spec/features/work_packages/details/date_editor_spec.rb +++ b/spec/features/work_packages/details/date_editor_spec.rb @@ -33,7 +33,7 @@ require "support/edit_fields/edit_field" require "features/work_packages/work_packages_page" -RSpec.describe "date inplace editor", :js, :selenium, with_settings: { date_format: "%Y-%m-%d" } do +RSpec.describe "date inplace editor", :js, with_settings: { date_format: "%Y-%m-%d" } do shared_let(:project) { create(:project_with_types, public: true) } shared_let(:user) { create(:admin) } shared_let(:type) { project.types.first } @@ -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 diff --git a/spec/support/edit_fields/date_edit_field.rb b/spec/support/edit_fields/date_edit_field.rb index 68aecb8df074..aab4b12db1bf 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=work_package[start_date]" + "input[name='work_package[start_date]']" else - "input[name=work_package[#{property_name.underscore}]" + "input[name='work_package[#{property_name.underscore}]']" 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! From 3f8bc3f8991cf11880710ca01b09b37ac12d59db Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Fri, 24 Jan 2025 09:00:33 +0100 Subject: [PATCH 62/92] Re-add selenium test --- spec/features/work_packages/details/date_editor_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/features/work_packages/details/date_editor_spec.rb b/spec/features/work_packages/details/date_editor_spec.rb index 0d75cdaf5e0b..79b86b40c699 100644 --- a/spec/features/work_packages/details/date_editor_spec.rb +++ b/spec/features/work_packages/details/date_editor_spec.rb @@ -33,7 +33,7 @@ require "support/edit_fields/edit_field" require "features/work_packages/work_packages_page" -RSpec.describe "date inplace editor", :js, with_settings: { date_format: "%Y-%m-%d" } do +RSpec.describe "date inplace editor", :js, :selenium, with_settings: { date_format: "%Y-%m-%d" } do shared_let(:project) { create(:project_with_types, public: true) } shared_let(:user) { create(:admin) } shared_let(:type) { project.types.first } From 83563aa9786b80a109723c611fc6f2945c5d0e4d Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Fri, 24 Jan 2025 12:10:20 +0100 Subject: [PATCH 63/92] Fix some more test selectors --- .../work_packages/date_picker/form_content_component.rb | 5 ++++- spec/features/work_packages/details/date_editor_spec.rb | 7 +++++-- spec/features/work_packages/table/duration_field_spec.rb | 5 +++-- spec/support/edit_fields/date_edit_field.rb | 2 +- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/app/components/work_packages/date_picker/form_content_component.rb b/app/components/work_packages/date_picker/form_content_component.rb index 331301830204..e7d1a19ada2e 100644 --- a/app/components/work_packages/date_picker/form_content_component.rb +++ b/app/components/work_packages/date_picker/form_content_component.rb @@ -73,7 +73,10 @@ def milestone? # Either the work package is a milestone OR in the create form, the angular 'date' field was triggered OR # in the WorkPackage create form, the datepicker dialog was already updated via Turbo # in which case the field param is overwritten and we have to check whether there is a due date field - @milestone ||= @work_package.milestone? || params[:field] == "date" || params[:work_package][:due_date].nil? + @milestone ||= + @work_package.milestone? || + params[:field] == "date" || + (params[:work_package].present? && params[:work_package][:due_date].nil?) end def disabled_checkbox? diff --git a/spec/features/work_packages/details/date_editor_spec.rb b/spec/features/work_packages/details/date_editor_spec.rb index 79b86b40c699..00fb4cddc1da 100644 --- a/spec/features/work_packages/details/date_editor_spec.rb +++ b/spec/features/work_packages/details/date_editor_spec.rb @@ -140,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" @@ -217,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 @@ -271,7 +274,7 @@ 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 context "with a date custom field" 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/support/edit_fields/date_edit_field.rb b/spec/support/edit_fields/date_edit_field.rb index aab4b12db1bf..ff395de07b48 100644 --- a/spec/support/edit_fields/date_edit_field.rb +++ b/spec/support/edit_fields/date_edit_field.rb @@ -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 From a8452a72ac800e732927f43c6c320bd8cbd576d2 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Fri, 24 Jan 2025 12:50:48 +0100 Subject: [PATCH 64/92] Fix some more test selectors --- .../table/scheduling/manual_scheduling_spec.rb | 16 ++++++++-------- .../datepicker/work_package_datepicker.rb | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) 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/work_package_datepicker.rb b/spec/support/components/datepicker/work_package_datepicker.rb index 2364151e7281..d731f125047d 100644 --- a/spec/support/components/datepicker/work_package_datepicker.rb +++ b/spec/support/components/datepicker/work_package_datepicker.rb @@ -126,12 +126,12 @@ def expect_scheduling_mode(manually) def expect_manual_scheduling_mode expect(container) - .to have_css('[data-test-selector="op-datepicker-modal--scheduling_manual"][data-qa-selected]="true"') + .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_css('[data-test-selector="op-datepicker-modal--scheduling_automatic"][data-qa-selected]="true"') + .to have_css('[data-test-selector="op-datepicker-modal--scheduling_automatic"][data-qa-selected="true"]') end def toggle_scheduling_mode From 304e7023569e3f03c962d772fc57b2ae9c8f078f Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Fri, 24 Jan 2025 15:11:15 +0100 Subject: [PATCH 65/92] Correct test expectations for Banner messages in the datepicker --- .../work_packages/details/date_editor_spec.rb | 95 +++++++++---------- 1 file changed, 47 insertions(+), 48 deletions(-) diff --git a/spec/features/work_packages/details/date_editor_spec.rb b/spec/features/work_packages/details/date_editor_spec.rb index 00fb4cddc1da..3df939b62065 100644 --- a/spec/features/work_packages/details/date_editor_spec.rb +++ b/spec/features/work_packages/details/date_editor_spec.rb @@ -277,6 +277,7 @@ 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 } @@ -356,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 @@ -382,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 @@ -414,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 @@ -436,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 @@ -464,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 @@ -477,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 @@ -512,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 @@ -534,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 @@ -567,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 @@ -589,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 From ef0febd2f701b37088850848e1411ea52e45189f Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Fri, 24 Jan 2025 17:10:28 +0100 Subject: [PATCH 66/92] Preload DatePicker PreviewController to make tests pass When in tests the date picker is opened to click on a day of the flatpickr, the controller is not loaded yet and some events are missed, making the test fail. The controller is not ready yet because it needs to be loaded dynamically by the OpApplicationController. By registering it in the setup.ts, it is loaded before the date picker modal is opened, and the controller is connected before a day is clicked, making (hopefully) the test pass. --- frontend/src/stimulus/setup.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts index aa6eb92c058a..23e1d7e13d11 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'; import KeepScrollPositionController from './controllers/keep-scroll-position.controller'; import PatternInputController from './controllers/pattern-input.controller'; import HoverCardTriggerController from './controllers/hover-card-trigger.controller'; @@ -47,5 +48,6 @@ 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); instance.register('keep-scroll-position', KeepScrollPositionController); instance.register('pattern-input', PatternInputController); From 5ee384f3864b2766d84ad806c6ceacde21ea7cf0 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Mon, 27 Jan 2025 08:56:41 +0100 Subject: [PATCH 67/92] Fix input selector for milestone in DateEditField --- spec/support/edit_fields/date_edit_field.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/spec/support/edit_fields/date_edit_field.rb b/spec/support/edit_fields/date_edit_field.rb index ff395de07b48..9cf6dede4f23 100644 --- a/spec/support/edit_fields/date_edit_field.rb +++ b/spec/support/edit_fields/date_edit_field.rb @@ -8,7 +8,6 @@ def initialize(context, selector: nil, is_milestone: false, is_table: false) - super(context, property_name, selector:) self.milestone = is_milestone self.is_table = is_table @@ -50,7 +49,9 @@ def input_selector def property_name if milestone - "date" + # when displaying date picker for milestone, only one date is displayed, + # and the input field name is `start_date`. + "start_date" else super end From 0aa7b0486a2c3c701b33381eed8516177463c4d1 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 27 Jan 2025 09:03:56 +0100 Subject: [PATCH 68/92] Remove unused services --- .../services/date-modal-relations.service.ts | 187 ------------------ .../services/date-modal-scheduling.service.ts | 61 ------ 2 files changed, 248 deletions(-) delete mode 100644 frontend/src/app/shared/components/datepicker/services/date-modal-relations.service.ts delete mode 100644 frontend/src/app/shared/components/datepicker/services/date-modal-scheduling.service.ts diff --git a/frontend/src/app/shared/components/datepicker/services/date-modal-relations.service.ts b/frontend/src/app/shared/components/datepicker/services/date-modal-relations.service.ts deleted file mode 100644 index 585ade029028..000000000000 --- a/frontend/src/app/shared/components/datepicker/services/date-modal-relations.service.ts +++ /dev/null @@ -1,187 +0,0 @@ -//-- copyright -// OpenProject is an open source project management software. -// Copyright (C) the OpenProject GmbH -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License version 3. -// -// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -// Copyright (C) 2006-2013 Jean-Philippe Lang -// Copyright (C) 2010-2013 the ChiliProject Team -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License -// as published by the Free Software Foundation; either version 2 -// of the License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -// -// See COPYRIGHT and LICENSE files for more details. -//++ - -import { - Injectable, -} from '@angular/core'; -import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; -import { ApiV3Filter } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder'; -import { WorkPackageChangeset } from 'core-app/features/work-packages/components/wp-edit/work-package-changeset'; -import { - defaultIfEmpty, - filter, - map, - shareReplay, - switchMap, - take, - tap, -} from 'rxjs/operators'; -import { - combineLatest, - Observable, - ReplaySubject, - Subject, -} from 'rxjs'; -import { HalResource } from 'core-app/features/hal/resources/hal-resource'; -import { IHALCollection } from 'core-app/core/apiv3/types/hal-collection.type'; -import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; -import { parseDate } from 'core-app/shared/components/datepicker/helpers/date-modal.helpers'; - -@Injectable() -export class DateModalRelationsService { - private changeset$:Subject = new ReplaySubject(); - private changeset:WorkPackageChangeset; - - setChangeset(changeset:WorkPackageChangeset) { - this.changeset$.next(changeset); - this.changeset = changeset; - } - - precedingWorkPackages$:Observable<{ id:string, dueDate?:string, date?:string }[]> = this.changeset$ - .pipe( - filter((changeset) => !isNewResource(changeset.pristineResource)), - switchMap((changeset) => this - .apiV3Service - .work_packages - .signalled( - ApiV3Filter('precedes', '=', [changeset.id]), - [ - 'elements/id', - 'elements/dueDate', - 'elements/date', - ], - )), - map((collection:IHALCollection<{ id:string }>) => collection._embedded.elements || []), - defaultIfEmpty([]), - shareReplay(1), - ); - - followingWorkPackages$:Observable<{ id:string }[]> = this.changeset$ - .pipe( - filter((changeset) => !isNewResource(changeset.pristineResource)), - switchMap((changeset) => this - .apiV3Service - .work_packages - .signalled( - ApiV3Filter('follows', '=', [changeset.id]), - ['elements/id'], - )), - map((collection:IHALCollection<{ id:string }>) => collection._embedded.elements || []), - defaultIfEmpty([]), - shareReplay(1), - ); - - hasRelations$ = combineLatest([ - this.precedingWorkPackages$, - this.followingWorkPackages$, - ]) - .pipe( - map(([precedes, follows]) => precedes.length > 0 || follows.length > 0 || this.isParent || this.isChild), - ); - - constructor( - private apiV3Service:ApiV3Service, - ) {} - - getMinimalDateFromPreceeding():Observable { - return this - .precedingWorkPackages$ - .pipe( - take(1), - map((relation) => this.minimalDateFromPrecedingRelationship(relation)), - ); - } - - private minimalDateFromPrecedingRelationship(relations:{ id:string, dueDate?:string, date?:string }[]):Date|null { - if (relations.length === 0) { - return null; - } - - let minimalDate:Date|null = null; - - relations.forEach((relation) => { - if (!relation.dueDate && !relation.date) { - return; - } - - const relationDate = relation.dueDate || relation.date; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const parsedRelationDate = parseDate(relationDate!); - - if (!minimalDate || minimalDate < parsedRelationDate) { - minimalDate = parsedRelationDate === '' ? null : parsedRelationDate; - } - }); - - return minimalDate; - } - - /** - * Determines whether the work package is a child. It does so - * by checking the ancestors links. - */ - get isChild():boolean { - return this.ancestors.length > 0; - } - - get ancestors():HalResource[] { - const wp = this.changeset.projectedResource; - return wp.getAncestors() || []; - } - - /** - * Determines whether the work package is a parent. It does so - * by checking the children links. - */ - get isParent():boolean { - return this.children.length > 0; - } - - get children():HalResource[] { - const wp = this.changeset.projectedResource; - return wp.children || []; - } - - getInvolvedWorkPackageIds():Observable { - return combineLatest([ - this.precedingWorkPackages$, - this.followingWorkPackages$, - ]) - .pipe( - map( - ([preceding, following]) => [ - this.changeset.pristineResource, - ...preceding, - ...following, - ...this.children, - ...this.ancestors, - ].map((el) => el.id as string), - ), - ); - } -} diff --git a/frontend/src/app/shared/components/datepicker/services/date-modal-scheduling.service.ts b/frontend/src/app/shared/components/datepicker/services/date-modal-scheduling.service.ts deleted file mode 100644 index bc395c79fb41..000000000000 --- a/frontend/src/app/shared/components/datepicker/services/date-modal-scheduling.service.ts +++ /dev/null @@ -1,61 +0,0 @@ -//-- copyright -// OpenProject is an open source project management software. -// Copyright (C) the OpenProject GmbH -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License version 3. -// -// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -// Copyright (C) 2006-2013 Jean-Philippe Lang -// Copyright (C) 2010-2013 the ChiliProject Team -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License -// as published by the Free Software Foundation; either version 2 -// of the License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -// -// See COPYRIGHT and LICENSE files for more details. -//++ - -import { - Injectable, -} from '@angular/core'; -import { WorkPackageChangeset } from 'core-app/features/work-packages/components/wp-edit/work-package-changeset'; -import { DateModalRelationsService } from 'core-app/shared/components/datepicker/services/date-modal-relations.service'; -import { DayElement } from 'flatpickr/dist/types/instance'; - -@Injectable() -export class DateModalSchedulingService { - private changeset:WorkPackageChangeset; - - scheduleManually = false; - - constructor( - readonly dateModalRelations:DateModalRelationsService, - ) {} - - setChangeset(changeset:WorkPackageChangeset) { - this.changeset = changeset; - this.scheduleManually = !!this.changeset.value('scheduleManually'); - } - - /** - * Returns whether the user can alter the dates of the work package. - */ - get isSchedulable():boolean { - return this.scheduleManually || !this.dateModalRelations.isParent; - } - - isDayDisabled(dayElement:DayElement, minimalDate?:Date|null):boolean { - return !this.isSchedulable || (!this.scheduleManually && !!minimalDate && dayElement.dateObj <= minimalDate); - } -} From fe31c806816a4511fb869d4e1dcc181979b0645a Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 27 Jan 2025 09:05:14 +0100 Subject: [PATCH 69/92] Fix banner expectation text and the condition for it to be shown --- .../date_picker/dialog_content_component.rb | 4 ++- .../work_packages/details/date_editor_spec.rb | 30 +++++++++++-------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/app/components/work_packages/date_picker/dialog_content_component.rb b/app/components/work_packages/date_picker/dialog_content_component.rb index a292e5fb286b..4cf40886d3a8 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.rb +++ b/app/components/work_packages/date_picker/dialog_content_component.rb @@ -84,7 +84,9 @@ def content_editable? end def show_banner? - @schedule_manually || has_relations? + follows_relations.any? || + children.any? || + (@schedule_manually && (precedes_relations.any? || work_package.parent_id.present?)) end def has_relations? diff --git a/spec/features/work_packages/details/date_editor_spec.rb b/spec/features/work_packages/details/date_editor_spec.rb index 3df939b62065..d65712cca9a5 100644 --- a/spec/features/work_packages/details/date_editor_spec.rb +++ b/spec/features/work_packages/details/date_editor_spec.rb @@ -358,7 +358,7 @@ it "shows a banner that the relations are ignored" do 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.", + text: "Manually scheduled. Dates not affected by relations.\nClick on \"Show relations\" for Gantt overview.", wait: 5) # When toggling manually scheduled @@ -368,6 +368,9 @@ expect(page).not_to have_test_selector("op-modal-banner-warning") expect(page).not_to have_test_selector("op-modal-banner-info") + # Toggle back to see the banner again + start_date.toggle_scheduling_mode + new_window = window_opened_by { click_on "Show relations" } switch_to_window new_window @@ -390,7 +393,7 @@ start_date.toggle_scheduling_mode 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.") + text: "Manually scheduled. Dates not affected by relations.\nClick on \"Show relations\" for Gantt overview.") end end end @@ -420,7 +423,7 @@ # Expect banner to switch 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.") + text: "The dates are determined by child work packages.\nClick on \"Show relations\" for Gantt overview.") new_window = window_opened_by { click_on "Show relations" } switch_to_window new_window @@ -436,7 +439,7 @@ 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.") + text: "The dates are determined by child work packages.\nClick on \"Show relations\" for Gantt overview.") # When toggling manually scheduled start_date.toggle_scheduling_mode @@ -463,7 +466,7 @@ 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").to_s, - text: "The dates are determined by child work packages. Click on “Show relations” for Gantt overview.") + text: "The dates are determined by child work packages.\nClick on \"Show relations\" for Gantt overview.") # Expect "Working days only" to be checked datepicker.expect_ignore_non_working_days_disabled @@ -484,7 +487,7 @@ datepicker.expect_ignore_non_working_days false, disabled: true 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.") + text: "The dates are determined by child work packages.\nClick on \"Show relations\" for Gantt overview.") end end end @@ -511,14 +514,14 @@ it "shows a banner that the relations are ignored" do 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.") + text: "Manually scheduled. Dates not affected by relations.\nClick 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").to_s, - text: "The start date is set by a predecessor. Click on “Show relations” for Gantt overview.") + text: "The start date is set by a predecessor.\nClick on \"Show relations\" for Gantt overview.") new_window = window_opened_by { click_on "Show relations" } switch_to_window new_window @@ -534,13 +537,13 @@ 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.") + text: "The start date is set by a predecessor.\nClick 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").to_s, - text: "Manually scheduled. Dates not affected by relations. Click on “Show relations” for Gantt overview.") + text: "Manually scheduled. Dates not affected by relations.\nClick on \"Show relations\" for Gantt overview.") end end end @@ -566,7 +569,7 @@ it "shows a banner that the relations are ignored" do 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.") + text: "Manually scheduled. Dates not affected by relations.\nClick on \"Show relations\" for Gantt overview.") # When toggling manually scheduled start_date.toggle_scheduling_mode @@ -575,6 +578,9 @@ expect(page).not_to have_test_selector("op-modal-banner-warning") expect(page).not_to have_test_selector("op-modal-banner-info") + # Toggle back to see the banner again + start_date.toggle_scheduling_mode + new_window = window_opened_by { click_on "Show relations" } switch_to_window new_window @@ -596,7 +602,7 @@ start_date.toggle_scheduling_mode 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.") + text: "Manually scheduled. Dates not affected by relations.\nClick on \"Show relations\" for Gantt overview.") end end end From d783780c61cd7084ba9eb22bbe2e966f18f89fb2 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Mon, 27 Jan 2025 09:37:53 +0100 Subject: [PATCH 70/92] Improve month selection for date picker tests After selecting a month, `#select_month` returns the index of the selected month. It previously worked well only for single-month datepickers. For a multiple months date picker, it was failing because the selector is invalid (so it waited 5 seconds for nothing), then it was retried, the current month was calculated, and that was the value returned as the 2 conditions were not met. So in the end it was returning the correct value, but it was also doing one useless retry. This commit introduces `#current_month_index` to return the index of the current month. This one works for both single and multi date pickers. It is used by `#select_month` to calculate the number of months to move forward or backward. This should lead to some faster tests. --- .../datepicker/month_range_selection.rb | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/spec/support/components/datepicker/month_range_selection.rb b/spec/support/components/datepicker/month_range_selection.rb index daa817a0494b..1051da0d9d7b 100644 --- a/spec/support/components/datepicker/month_range_selection.rb +++ b/spec/support/components/datepicker/month_range_selection.rb @@ -5,24 +5,39 @@ module MonthRangeSelection def select_month(month) month = Date::MONTHNAMES.index(month) if month.is_a?(String) retry_block do - # This is for a double-month datepicker - current_month_element = flatpickr_container.all(".cur-month", wait: 0).first - current_month = if current_month_element - Date::MONTHNAMES.index(current_month_element.text) - else - # This is for a single-month datepicker - flatpickr_container.first(".flatpickr-monthDropdown-months").value.to_i + 1 - end + current_month = current_month_index if current_month < month month_difference = month - current_month month_difference.times { flatpickr_container.find(".flatpickr-next-month").click } - flatpickr_container.first(".flatpickr-monthDropdown-months").value.to_i + 1 elsif current_month > month month_difference = current_month - month month_difference.times { flatpickr_container.find(".flatpickr-prev-month").click } - flatpickr_container.first(".flatpickr-monthDropdown-months").value.to_i + 1 end + current_month_index + end + end + + # Returns the index of the current month. + # + # 1 for January, 2 for February, etc. + # + # When multiple months are displayed, it returns the value for the first one + # displayed. + def current_month_index + # ensure flatpicker month is displayed + flatpickr_container.first(".flatpickr-month") + + # Checking if showing multiple months or using `monthSelectorType: "static"`, + # in which case the month is simply some static text in a span instead of a + # `