diff --git a/app/components/_index.sass b/app/components/_index.sass index 59f6c3cdfa43..b11ab3afed3c 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -5,6 +5,8 @@ @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" @import "shares/invite_user_form_component" diff --git a/app/components/work_package_relations_tab/relation_component.rb b/app/components/work_package_relations_tab/relation_component.rb index 5df345651d22..886ee273a393 100644 --- a/app/components/work_package_relations_tab/relation_component.rb +++ b/app/components/work_package_relations_tab/relation_component.rb @@ -2,16 +2,18 @@ class WorkPackageRelationsTab::RelationComponent < ApplicationComponent include ApplicationHelper include OpPrimer::ComponentHelpers - attr_reader :work_package, :relation, :child + attr_reader :work_package, :relation, :child, :editable def initialize(work_package:, relation:, - child: nil) + child: nil, + editable: true) super() @work_package = work_package @relation = relation @child = child + @editable = editable end def related_work_package @@ -32,6 +34,8 @@ def should_render_edit_option? end def should_render_action_menu? + return false unless editable + if parent_child_relationship? allowed_to_manage_subtasks? else 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..6f7bde116710 --- /dev/null +++ b/app/components/work_packages/date_picker/banner_component.html.erb @@ -0,0 +1,15 @@ +<%= + 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 new file mode 100644 index 000000000000..c093290ed159 --- /dev/null +++ b/app/components/work_packages/date_picker/banner_component.rb @@ -0,0 +1,159 @@ +#-- 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 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? + 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 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 + + 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 + + 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, + test_selector: + } + 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 new file mode 100644 index 000000000000..d00a0effd790 --- /dev/null +++ b/app/components/work_packages/date_picker/dialog_content_component.html.erb @@ -0,0 +1,75 @@ +<%= + content_tag("turbo-frame", id: "wp-datepicker-dialog--content") do + component_wrapper(data: { "application-target": "dynamic", + controller: "work-packages--date-picker--preview", + test_selector: "op-datepicker-modal" }, + class: "wp-datepicker-dialog--content") do + component_collection do |collection| + if show_banner? + 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--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| + 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, + show_date_form: content_editable?, + work_package:, + schedule_manually:, + focused_field:, + touched_field_map:)) + end + end + + 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 + 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 + end + end + end + + 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" }, + 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: !content_editable?)) do + I18n.t("button_save") + 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..a292e5fb286b --- /dev/null +++ b/app/components/work_packages/date_picker/dialog_content_component.rb @@ -0,0 +1,95 @@ +#-- 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 + + DIALOG_FORM_ID = "datepicker-form" + + attr_accessor :work_package, :schedule_manually, :focused_field, :touched_field_map + + def initialize(work_package:, schedule_manually: true, focused_field: :start_date, touched_field_map: {}) + super + + @work_package = work_package + @schedule_manually = ActiveModel::Type::Boolean.new.cast(schedule_manually) + @focused_field = focused_field + @touched_field_map = touched_field_map + end + + private + + def precedes_relations + @precedes_relations ||= work_package.precedes_relations + end + + def follows_relations + @follows_relations ||= work_package.follows_relations + end + + def children + @children ||= work_package.children + end + + def additional_tabs + [ + { + key: "predecessors", + relations: follows_relations + }, + { + key: "successors", + relations: precedes_relations + }, + { + key: "children", + relations: children, + is_child_relation?: true + } + ] + end + + 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 + end + end +end 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..99f9dffd1028 --- /dev/null +++ b/app/components/work_packages/date_picker/dialog_content_component.sass @@ -0,0 +1,28 @@ +@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) + .wp-datepicker-dialog + &--UnderlineNav + display: none !important + &--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 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..8289be4dbcca --- /dev/null +++ b/app/components/work_packages/date_picker/form_content_component.html.erb @@ -0,0 +1,92 @@ +<%= + 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"), 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, + 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, + 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"), + 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_checkbox?, + test_selector: "op-datepicker-modal--ignore-non-working-days", + value: 0, + unchecked_value: 1, + data: { "work-packages--date-picker--preview-target": "fieldInput", + action: "work-packages--date-picker--preview#markFieldAsTouched" })) + end + end + end + + if show_date_form + body.with_row(mb: 3, classes: "FormControl-horizontalGroup--sm-vertical") do + render(WorkPackages::DatePicker::DateForm.new(f, + work_package:, + schedule_manually:, + is_milestone: milestone?, + 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: milestone?, + start_date_field_id: "work_package_start_date", + due_date_field_id: "work_package_due_date", + duration_field_id: "work_package_duration" + } + 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..e7d1a19ada2e --- /dev/null +++ b/app/components/work_packages/date_picker/form_content_component.rb @@ -0,0 +1,87 @@ +#-- 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, :show_date_form, :work_package, :schedule_manually, :focused_field, :touched_field_map + + def initialize(form_id:, + show_date_form:, + work_package:, + schedule_manually: true, + focused_field: :start_date, + touched_field_map: {}) + super + + @form_id = form_id + @show_date_form = show_date_form + @work_package = work_package + @schedule_manually = ActiveModel::Type::Boolean.new.cast(schedule_manually) + @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 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].present? && params[:work_package][:due_date].nil?) + end + + def disabled_checkbox? + !schedule_manually && work_package.children.any? + end + end + end +end diff --git a/app/contracts/work_packages/base_contract.rb b/app/contracts/work_packages/base_contract.rb index 4ab9a9c5cfb3..7acd4b431d40 100644 --- a/app/contracts/work_packages/base_contract.rb +++ b/app/contracts/work_packages/base_contract.rb @@ -483,7 +483,20 @@ def validate_people_visible(attribute, id_attribute, list) end def validate_duration_integer - errors.add :duration, :not_an_integer if model.duration_before_type_cast != model.duration + 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 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..55063f102b66 --- /dev/null +++ b/app/controllers/work_packages/date_picker_controller.rb @@ -0,0 +1,244 @@ +# 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, except: %i[new create] + authorization_checked! :show, :update, :edit, :new, :create + + attr_accessor :work_package + + def show + respond_to do |format| + format.html do + render :show, + locals: { work_package:, schedule_manually:, params: }, + layout: false + end + + format.turbo_stream do + set_date_attributes_to_work_package + + replace_via_turbo_stream( + component: WorkPackages::DatePicker::DialogContentComponent.new(work_package:, + schedule_manually:, + focused_field:, + touched_field_map:) + ) + render turbo_stream: turbo_streams + end + 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, + 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("wp-datepicker-dialog--content", datepicker_modal_component) + ], status: :unprocessable_entity + end + end + end + end + + private + + def datepicker_modal_component + WorkPackages::DatePicker::DialogContentComponent.new(work_package: @work_package, + schedule_manually:, + focused_field:, + touched_field_map:) + end + + def focused_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 + @work_package = WorkPackage.visible.find(params[:work_package_id]) + end + + def touched_field_map + if params[:work_package] + 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! + else + {} + end + end + + def schedule_manually + find_if_present(params[:schedule_manually]) || + find_if_present(params.dig(:work_package, :schedule_manually)) || + work_package.schedule_manually + end + + def find_if_present(value) + value.presence + end + + def work_package_datepicker_params + if params[:work_package] + handle_milestone_dates + + params.require(:work_package) + .slice(*allowed_touched_params) + .merge(schedule_manually:) + .permit! + end + 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 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 + + if wp_params.present? + WorkPackages::SetAttributesService + .new(user: current_user, + model: @work_package, + contract_class: WorkPackages::CreateContract) + .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/controllers/work_packages_controller.rb b/app/controllers/work_packages_controller.rb index 36e8a12f8a15..6e23024c6d74 100644 --- a/app/controllers/work_packages_controller.rb +++ b/app/controllers/work_packages_controller.rb @@ -174,7 +174,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/forms/work_packages/date_picker/date_form.rb b/app/forms/work_packages/date_picker/date_form.rb new file mode 100644 index 000000000000..14a9a524ee43 --- /dev/null +++ b/app/forms/work_packages/date_picker/date_form.rb @@ -0,0 +1,211 @@ +# 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. +# ++ + +module WorkPackages + module DatePicker + 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 + # a11y-friendly, it breaks the modal's UX when an invalid input field + # is rendered. + # + # The reason for this is since we're implementing a "format on blur", when + # we make a request to the server that will set an input field in an invalid + # state and it is returned as such, any time we blur this autofocused field, + # we'll perform another request that will still have the input in an invalid + # state causing it to autofocus again and preventing us from leaving this + # "limbo state". + ## + def before_render + # no-op + end + + attr_reader :work_package + + 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 = is_milestone + @focused_field = update_focused_field(focused_field) + @touched_field_map = touched_field_map + @disabled = disabled + end + + 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")) + + 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) + hidden_touched_field(group, name: :schedule_manually) + + group.fields_for(:initial) do |builder| + WorkPackages::DatePicker::InitialValuesForm.new(builder, work_package:, is_milestone: @is_milestone) + end + end + end + + private + + def text_field(group, name:, label:) + text_field_options = default_field_options(name).merge( + name:, + value: field_value(name), + disabled: disabled?(name), + 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) + ) + + if duration_field?(name) + 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 + + 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: "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 + + def hidden_touched_field(group, name:) + group.hidden(name: :"#{name}_touched", + value: touched(name), + data: { "work-packages--date-picker--preview-target": "touchedFieldInput", + "referrer-field": name }) + end + + def touched(name) + @touched_field_map["#{name}_touched"] || false + end + + def duration_field?(name) + name == :duration + end + + def update_focused_field(focused_field) + return :start_date if focused_field.nil? + + 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 + :start_date + end + 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? }) + user_value + else + @work_package.public_send(name) + end + end + + def validation_message(name) + # it's ok to take the first error only, that's how primer_view_component does it anyway. + message = @work_package.errors.messages_for(name).first + message&.upcase_first + end + + 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", + 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 + { data: } + end + 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 new file mode 100644 index 000000000000..ef998812f86b --- /dev/null +++ b/app/forms/work_packages/date_picker/initial_values_form.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2010-2024 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. +# ++ + +module WorkPackages + module DatePicker + class InitialValuesForm < ApplicationForm + attr_reader :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: :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 + + def hidden_initial_field(form, name:) + form.hidden(name:, + value: work_package.public_send(:"#{name}_was"), + data: { "work-packages--date-pick--preview-target": "initialValueInput", + "referrer-field": name }) + end + end + end +end diff --git a/app/forms/work_packages/progress_form.rb b/app/forms/work_packages/progress_form.rb index 5c9e82679446..bd486c991509 100644 --- a/app/forms/work_packages/progress_form.rb +++ b/app/forms/work_packages/progress_form.rb @@ -197,7 +197,7 @@ def as_percent(value) end def default_field_options(name) - data = { "work-packages--progress--preview-target": "progressInput", + data = { "work-packages--progress--preview-target": "fieldInput", action: "work-packages--progress--preview#markFieldAsTouched" } if @focused_field == name diff --git a/app/models/work_packages/relations.rb b/app/models/work_packages/relations.rb index 12dc4d3da507..ff26452e3124 100644 --- a/app/models/work_packages/relations.rb +++ b/app/models/work_packages/relations.rb @@ -51,6 +51,18 @@ module WorkPackages::Relations dependent: :nullify, inverse_of: :from + # Relations where the current work package is followed by another one. + # In this case, + # * from is the following work package + # * to is self + has_many :precedes_relations, + -> { where(relation_type: Relation::TYPE_FOLLOWS) }, + class_name: "Relation", + foreign_key: :to_id, + autosave: true, + dependent: :nullify, + inverse_of: :to + # Relations where the current work package blocks another one. # In this case, # * from is self.id diff --git a/app/views/work_packages/date_picker/show.html.erb b/app/views/work_packages/date_picker/show.html.erb new file mode 100644 index 000000000000..a8198c36dbe3 --- /dev/null +++ b/app/views/work_packages/date_picker/show.html.erb @@ -0,0 +1 @@ +<%= render(WorkPackages::DatePicker::DialogContentComponent.new(work_package:, schedule_manually:, focused_field: params[:field]))%> diff --git a/config/locales/en.yml b/config/locales/en.yml index 9d8b7c414ab9..1b3654d4ed93 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -688,6 +688,48 @@ en: no_results_title_text: There are currently no workflows. work_packages: + 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" + 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: + title: "Scheduling mode" + automatic: "Automatic" + manual: "Manual" + show_relations: "Show relations" + tabs: + aria_label: "Datepicker tabs" + children: "Children" + 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" other: "%{count} work package descendants" diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index fd80166a7665..8753429675e4 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -933,9 +933,6 @@ en: project_menu_details: "Details" - scheduling: - manual: "Manual scheduling" - sort: sorted_asc: "Ascending sort applied, " sorted_dsc: "Descending sort applied, " @@ -984,15 +981,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 cb8eabdbace1..36de1b69f876 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -643,6 +643,18 @@ 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 :datepicker_dialog_content, + only: :create, + controller: "work_packages/date_picker" + end + resources :relations_tab, only: %i[index], controller: "work_package_relations_tab" resources :relations, only: %i[new create edit update destroy], controller: "work_package_relations" diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index c3db5148528f..8fdecd8178eb 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -237,6 +237,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 () => { @@ -421,7 +422,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 }); @@ -454,7 +455,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-basic-single-date-picker', OpBasicSingleDatePickerComponent, { 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 d98bd51724e3..089ffd4eb159 100644 --- a/frontend/src/app/core/path-helper/path-helper.service.ts +++ b/frontend/src/app/core/path-helper/path-helper.service.ts @@ -304,6 +304,14 @@ export class PathHelperService { return `${this.workPackagePath(workPackageId)}/split_view/update_counter?counter=${counter}`; } + public workPackageDatepickerDialogContentPath(workPackageId:string|number):string { + if (workPackageId === 'new') { + return `${this.workPackagePath(workPackageId)}/datepicker_dialog_content/new`; + } + + return `${this.workPackagePath(workPackageId)}/datepicker_dialog_content`; + } + // Work Package Bulk paths public workPackagesBulkEditPath() { 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/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/datepicker.module.ts b/frontend/src/app/shared/components/datepicker/datepicker.module.ts index 3cfc24d62ef0..dc22fd0861e1 100644 --- a/frontend/src/app/shared/components/datepicker/datepicker.module.ts +++ b/frontend/src/app/shared/components/datepicker/datepicker.module.ts @@ -8,15 +8,12 @@ import { CommonModule } from '@angular/common'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { OpModalSingleDatePickerComponent } from './modal-single-date-picker/modal-single-date-picker.component'; -import { OpWpMultiDateFormComponent } from './wp-multi-date-form/wp-multi-date-form.component'; -import { OpWpSingleDateFormComponent } from './wp-single-date-form/wp-single-date-form.component'; -import { 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'; 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 { OpWpModalDatePickerComponent } from 'core-app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component'; @NgModule({ imports: [ @@ -27,6 +24,7 @@ import { OpDatePickerSheetComponent } from 'core-app/shared/components/datepicke OpSpotModule, OpBasicDatePickerModule, OpenprojectModalModule, + OpenprojectContentLoaderModule, ], providers: [ @@ -34,22 +32,16 @@ import { OpDatePickerSheetComponent } from 'core-app/shared/components/datepicke ], declarations: [ - OpDatePickerBannerComponent, - OpDatePickerSchedulingToggleComponent, - OpDatePickerWorkingDaysToggleComponent, - OpModalSingleDatePickerComponent, - OpWpMultiDateFormComponent, - OpWpSingleDateFormComponent, OpDatePickerSheetComponent, + OpWpModalDatePickerComponent, ], exports: [ OpModalSingleDatePickerComponent, - OpWpMultiDateFormComponent, - OpWpSingleDateFormComponent, OpBasicDatePickerModule, OpDatePickerSheetComponent, + OpWpModalDatePickerComponent, ], }) export class OpDatePickerModule { } 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/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/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/styles/datepicker.modal.sass b/frontend/src/app/shared/components/datepicker/styles/datepicker.modal.sass deleted file mode 100644 index 904eb15044cd..000000000000 --- a/frontend/src/app/shared/components/datepicker/styles/datepicker.modal.sass +++ /dev/null @@ -1,94 +0,0 @@ -@import '../../app/spot/styles/sass/variables' -@import '../../global_styles/openproject/variables' -.op-datepicker-modal - display: flex - flex-direction: column - height: 100% - - @media #{$spot-mq-drop-modal-in-context} - height: unset - min-height: 475px // Avoid jump in height when the calendar has 6 rows to display all weeks - width: 100vw - // Basically the width of the two calendars next to each other + spacings - // will be overwritten on mobile - max-width: 320px - - &_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 - - &--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 - - &--date-container - display: inline-grid - - &--hidden-link - visibility: hidden - - &--flatpickr-instance.inline - 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 - width: calc(100vw - 2rem) - margin-bottom: 0 - - &--flatpickr-instance - align-self: center - - &--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 - -.flatpickr-wrapper - // Make flatpickr behave correctly when it is instantiated - // inside a dialog using the static: true option. - width: 100% diff --git a/frontend/src/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-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..3bec749ed470 --- /dev/null +++ b/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts @@ -0,0 +1,355 @@ +//-- 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, + 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 { onDayCreate } from 'core-app/shared/components/datepicker/helpers/date-modal.helpers'; +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, Subject } from 'rxjs'; +import { debounceTime, filter } from 'rxjs/operators'; +import * as _ from 'lodash'; + +@Component({ + selector: 'op-wp-modal-date-picker', + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements AfterViewInit { + @Input() public ignoreNonWorkingDays:boolean; + @Input() public scheduleManually:boolean; + + @Input() public startDate:Date|null; + @Input() public dueDate:Date|null; + + @Input() public isSchedulable:boolean = true; + @Input() public minimalSchedulingDate:Date|null; + + @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; + private initializeDatepickerSubject = new Subject(); + + constructor( + readonly injector:Injector, + readonly cdRef:ChangeDetectorRef, + readonly apiV3Service:ApiV3Service, + readonly I18n:I18nService, + readonly timezoneService:TimezoneService, + readonly deviceService:DeviceService, + readonly pathHelper:PathHelperService, + readonly elementRef:ElementRef, + ) { + 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.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 + ngOnDestroy():void { + 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) { + const details = (event.detail as { field:string, value:string }); + + switch (details.field) { + case 'work_package[start_date]': + this.startDate = this.toDate(details.value); + break; + case 'work_package[due_date]': + this.dueDate = this.toDate(details.value); + break; + case 'work_package[ignore_non_working_days]': + this.ignoreNonWorkingDays = details.value !== 'true'; + break; + default: + // Case fallthrough for eslint + return; + } + + // Emit an event to the subject, which will be debounced and trigger the datepicker initialization + 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); + } + return null; + } + + private currentDates():Date[] { + return _.compact([this.startDate, this.dueDate]); + } + + private initializeDatepicker() { + this.datePickerInstance?.destroy(); + this.fieldName = this.getActiveField(); + const ignoreNonWorkingDaysTemp = this.ignoreNonWorkingDays; + + this.datePickerInstance = new DatePicker( + this.injector, + '#flatpickr-input', + this.currentDates(), + { + mode: this.mode, + 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: 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( + dayElem, + ignoreNonWorkingDaysTemp, + await this.datePickerInstance?.isNonWorkingDay(dayElem.dateObj), + this.isDayDisabled(dayElem), + ); + }, + }, + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + this.flatpickrTarget.nativeElement, + ); + } + + 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(); + + 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); + } + + /** + * 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')); + } + + 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|null, fieldId:string | null):void { + if (fieldId) { + const field = document.getElementById(fieldId) as HTMLInputElement; + if (date) { + field.value = this.timezoneService.formattedISODate(date); + } else { + field.value = ''; + } + field.dispatchEvent(new Event('input')); + } + } +} diff --git a/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.html b/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.html deleted file mode 100644 index 90d1fb365405..000000000000 --- a/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.html +++ /dev/null @@ -1,131 +0,0 @@ -
- - -
-
- - -
- -
- - - - - - - - - - - -
- - -
- -
-
- - -
-
-
diff --git a/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.ts b/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.ts deleted file mode 100644 index d1dd3a218f95..000000000000 --- a/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.ts +++ /dev/null @@ -1,837 +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'; - -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(); - - 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, - ) { - 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); - } - - 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/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 dc89ece75d05..000000000000 --- a/frontend/src/app/shared/components/datepicker/wp-single-date-form/wp-single-date-form.component.html +++ /dev/null @@ -1,76 +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 6e6c66fbde87..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,20 +13,24 @@ /> - - + + + + + + + + + + + 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 0644b1ae725f..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 @@ -26,14 +26,17 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { Component } from '@angular/core'; +import { + Component, + 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'; @Component({ templateUrl: './combined-date-edit-field.component.html', }) -export class CombinedDateEditFieldComponent extends DatePickerEditFieldComponent { +export class CombinedDateEditFieldComponent extends DatePickerEditFieldComponent implements OnInit { dates = ''; opened = false; @@ -46,8 +49,8 @@ export class CombinedDateEditFieldComponent extends DatePickerEditFieldComponent }, }; - get isMultiDate():boolean { - return !this.change.schema.isMilestone; + ngOnInit() { + super.ngOnInit(); } public onInputClick(event:MouseEvent) { @@ -55,6 +58,7 @@ export class CombinedDateEditFieldComponent extends DatePickerEditFieldComponent } public showDatePickerModal():void { + this.updateFrameSrc(); this.opened = true; } @@ -68,12 +72,13 @@ export class CombinedDateEditFieldComponent extends DatePickerEditFieldComponent } public save():void { - this.handler.handleUserSubmit(); - this.onModalClosed(); + void this.handler.handleUserSubmit(); } public cancel():void { - this.handler.reset(); + if (!this.handler.inEditMode) { + this.handler.reset(); + } this.onModalClosed(); } @@ -107,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]); } } 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..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 @@ -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,66 @@ export abstract class DatePickerEditFieldComponent extends EditFieldComponent im } public showDatePickerModal():void { } + + public handleSuccessfulCreate(JSONResponse:{ duration:number, startDate:Date, dueDate:Date, includeNonWorkingDays:boolean, scheduleManually:boolean }):void { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.duration = JSONResponse.duration ? this.timezoneService.toISODuration(JSONResponse.duration, 'days') : null; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.dueDate = JSONResponse.dueDate; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.startDate = JSONResponse.startDate; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.includeNonWorkingDays = JSONResponse.includeNonWorkingDays; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.scheduleManually = JSONResponse.scheduleManually; + + this.onModalClosed(); + } + + public handleSuccessfulUpdate():void { + this.onModalClosed(); + } + + public onModalClosed():void { } + + public updateFrameSrc():void { + const url = new URL( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + this.pathHelper.workPackageDatepickerDialogContentPath(this.resource.id as string), + window.location.origin, + ); + + url.searchParams.set('field', this.name); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access + url.searchParams.set('work_package[initial][start_date]', this.nullAsEmptyStringFormatter(this.resource.startDate)); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access + url.searchParams.set('work_package[initial][due_date]', this.nullAsEmptyStringFormatter(this.resource.dueDate)); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access + url.searchParams.set('work_package[initial][duration]', this.nullAsEmptyStringFormatter(this.resource.duration)); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + url.searchParams.set('work_package[initial][ignore_non_working_days]', this.nullAsEmptyStringFormatter(this.resource.includeNonWorkingDays)); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access + url.searchParams.set('work_package[start_date]', this.nullAsEmptyStringFormatter(this.resource.startDate)); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access + url.searchParams.set('work_package[due_date]', this.nullAsEmptyStringFormatter(this.resource.dueDate)); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access + url.searchParams.set('work_package[duration]', this.nullAsEmptyStringFormatter(this.resource.duration)); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + url.searchParams.set('work_package[ignore_non_working_days]', this.nullAsEmptyStringFormatter(this.resource.includeNonWorkingDays)); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (this.resource?.id === 'new') { + url.searchParams.set('work_package[start_date_touched]', 'true'); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + this.turboFrameSrc = url.toString(); + } + + private nullAsEmptyStringFormatter(value:null|string):string { + if (value === undefined || value === null) { + return ''; + } + return value; + } } diff --git a/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.html b/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.html index 1e64d5f96dfb..316ecf8f304e 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.html +++ b/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.html @@ -13,12 +13,25 @@ disabled="disabled" [id]="handler.htmlId" /> - - + + + + + + + + + + + + + diff --git a/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.ts index 6545320ea559..36a166bc0748 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.ts @@ -29,6 +29,7 @@ import { ChangeDetectionStrategy, Component, + OnInit, } from '@angular/core'; import { DatePickerEditFieldComponent } from 'core-app/shared/components/fields/edit/field-types/date-picker-edit-field.component'; import * as moment from 'moment-timezone'; @@ -37,7 +38,7 @@ import * as moment from 'moment-timezone'; templateUrl: './days-duration-edit-field.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class DaysDurationEditFieldComponent extends DatePickerEditFieldComponent { +export class DaysDurationEditFieldComponent extends DatePickerEditFieldComponent implements OnInit { opened = false; public get formattedValue():number { @@ -56,8 +57,8 @@ export class DaysDurationEditFieldComponent extends DatePickerEditFieldComponent this.opened = true; } - save() { - this.handler.handleUserSubmit(); + onModalClosed() { + void this.handler.handleUserSubmit(); this.opened = false; } diff --git a/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.html b/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.html index 73a7cba63181..d3e433c89e15 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.html +++ b/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.html @@ -37,9 +37,15 @@
+ [attr.src]="this.frameSrc" + opModalWithTurboContent + [change]="change" + [resource]="resource" + (successfulCreate)="handleSuccessfulCreate($event)" + (successfulUpdate)="handleSuccessfulUpdate()" + (cancel)="cancel()" + > diff --git a/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.ts index 8abf179696ee..8660af62d91b 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.ts @@ -29,22 +29,17 @@ */ import { - AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Inject, Injector, - OnDestroy, OnInit, - ViewChild, } from '@angular/core'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; -import { - ProgressEditFieldComponent, -} from 'core-app/shared/components/fields/edit/field-types/progress-edit-field.component'; +import { ProgressEditFieldComponent } from 'core-app/shared/components/fields/edit/field-types/progress-edit-field.component'; import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { IFieldSchema } from 'core-app/shared/components/fields/field.base'; @@ -55,7 +50,6 @@ import { OpEditingPortalSchemaToken, } from 'core-app/shared/components/fields/edit/edit-field.component'; import { HalEventsService } from 'core-app/features/hal/services/hal-events.service'; -import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { ToastService } from 'core-app/shared/components/toaster/toast.service'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; import { TimezoneService } from 'core-app/core/datetime/timezone.service'; @@ -65,9 +59,7 @@ import { TimezoneService } from 'core-app/core/datetime/timezone.service'; styleUrls: ['./progress-popover-edit-field.component.sass'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ProgressPopoverEditFieldComponent extends ProgressEditFieldComponent implements OnInit, AfterViewInit, OnDestroy { - @ViewChild('frameElement') frameElement:ElementRef; - +export class ProgressPopoverEditFieldComponent extends ProgressEditFieldComponent implements OnInit { text = { title: this.I18n.t('js.work_packages.progress.title'), button_close: this.I18n.t('js.button_close'), @@ -107,22 +99,6 @@ export class ProgressPopoverEditFieldComponent extends ProgressEditFieldComponen this.frameId = 'work_package_progress_modal'; } - ngAfterViewInit() { - this - .frameElement - .nativeElement - .addEventListener('turbo:submit-end', this.contextBasedListener.bind(this)); - } - - ngOnDestroy() { - super.ngOnDestroy(); - - this - .frameElement - .nativeElement - .removeEventListener('turbo:submit-end', this.contextBasedListener.bind(this)); - } - public get asHoursOrPercent():string { return this.name === 'percentageDone' ? this.asPercent : this.asHours; } @@ -156,62 +132,19 @@ export class ProgressPopoverEditFieldComponent extends ProgressEditFieldComponen return value; } - private contextBasedListener(event:CustomEvent) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (this.resource.id === 'new') { - void this.propagateSuccessfulCreate(event); - } else { - this.propagateSuccessfulUpdate(event); - } - } - - private async propagateSuccessfulCreate(event:CustomEvent) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const { fetchResponse } = event.detail; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (fetchResponse.succeeded) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access - const JSONresponse = await this.extractJSONFromResponse(fetchResponse.response.body); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment - this.resource.estimatedTime = JSONresponse.estimatedTime; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment - this.resource.remainingTime = JSONresponse.remainingTime; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment - this.resource.percentageDone = JSONresponse.percentageDone; + public handleSuccessfulCreate(JSONResponse:{ estimatedTime:string, remainingTime:string, percentageDone:string }):void { +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.estimatedTime = JSONResponse.estimatedTime; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.remainingTime = JSONResponse.remainingTime; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.percentageDone = JSONResponse.percentageDone; - this.onModalClosed(); - - this.change.push(); - this.cdRef.detectChanges(); - } - } - - private propagateSuccessfulUpdate(event:CustomEvent) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const { fetchResponse } = event.detail; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (fetchResponse.succeeded) { - this.halEvents.push( - this.resource as WorkPackageResource, - { eventType: 'updated' }, - ); - - void this.apiV3Service.work_packages.id(this.resource as WorkPackageResource).refresh(); - - this.onModalClosed(); - - this.toastService.addSuccess(this.I18n.t('js.notice_successful_update')); - } + this.onModalClosed(); } - private async extractJSONFromResponse(response:ReadableStream) { - const readStream = await response.getReader().read(); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return JSON.parse(new TextDecoder('utf-8').decode(new Uint8Array(readStream.value as ArrayBufferLike))); + public handleSuccessfulUpdate():void { + this.onModalClosed(); } private updateFrameSrc():void { diff --git a/frontend/src/app/shared/components/fields/edit/modal-with-turbo-content/modal-with-turbo-content.directive.ts b/frontend/src/app/shared/components/fields/edit/modal-with-turbo-content/modal-with-turbo-content.directive.ts new file mode 100644 index 000000000000..c6ecda1d8139 --- /dev/null +++ b/frontend/src/app/shared/components/fields/edit/modal-with-turbo-content/modal-with-turbo-content.directive.ts @@ -0,0 +1,139 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { + AfterViewInit, + ChangeDetectorRef, + Directive, + ElementRef, + EventEmitter, + Input, + OnDestroy, + Output, +} from '@angular/core'; +import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { HalEventsService } from 'core-app/features/hal/services/hal-events.service'; +import { ToastService } from 'core-app/shared/components/toaster/toast.service'; +import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset'; + +@Directive({ + selector: '[opModalWithTurboContent]', +}) +export class ModalWithTurboContentDirective implements AfterViewInit, OnDestroy { + @Input() resource:HalResource; + @Input() change:ResourceChangeset; + + @Output() successfulCreate= new EventEmitter(); + @Output() successfulUpdate= new EventEmitter(); + @Output() cancel= new EventEmitter(); + + constructor( + readonly elementRef:ElementRef, + readonly cdRef:ChangeDetectorRef, + readonly halEvents:HalEventsService, + readonly apiV3Service:ApiV3Service, + readonly toastService:ToastService, + readonly I18n:I18nService, + ) { + + } + + ngAfterViewInit() { + (this.elementRef.nativeElement as HTMLElement) + .addEventListener('turbo:submit-end', this.contextBasedListener.bind(this)); + + document + .addEventListener('cancelModalWithTurboContent', this.cancelListener.bind(this)); + } + + ngOnDestroy() { + (this.elementRef.nativeElement as HTMLElement) + .removeEventListener('turbo:submit-end', this.contextBasedListener.bind(this)); + + document + .removeEventListener('cancelModalWithTurboContent', this.cancelListener.bind(this)); + } + + private contextBasedListener(event:CustomEvent) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (this.resource.id === 'new') { + void this.propagateSuccessfulCreate(event); + } else { + this.propagateSuccessfulUpdate(event); + } + } + + private cancelListener():void { + this.cancel.emit(); + } + + private async propagateSuccessfulCreate(event:CustomEvent) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { fetchResponse } = event.detail; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (fetchResponse.succeeded) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access + const JSONresponse:unknown = await this.extractJSONFromResponse(fetchResponse.response.body); + + this.successfulCreate.emit(JSONresponse); + + this.change.push(); + this.cdRef.detectChanges(); + } + } + + private propagateSuccessfulUpdate(event:CustomEvent) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { fetchResponse } = event.detail; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (fetchResponse.succeeded) { + this.halEvents.push( + this.resource as WorkPackageResource, + { eventType: 'updated' }, + ); + + void this.apiV3Service.work_packages.id(this.resource as WorkPackageResource).refresh(); + + this.successfulUpdate.emit(); + + this.toastService.addSuccess(this.I18n.t('js.notice_successful_update')); + } + } + + private async extractJSONFromResponse(response:ReadableStream) { + const readStream = await response.getReader().read(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return JSON.parse(new TextDecoder('utf-8').decode(new Uint8Array(readStream.value as ArrayBufferLike))); + } +} diff --git a/frontend/src/app/shared/components/fields/openproject-fields.module.ts b/frontend/src/app/shared/components/fields/openproject-fields.module.ts index ffb58fa6bad5..ef4a2c64b97f 100644 --- a/frontend/src/app/shared/components/fields/openproject-fields.module.ts +++ b/frontend/src/app/shared/components/fields/openproject-fields.module.ts @@ -106,6 +106,7 @@ import { import { CombinedDateEditFieldComponent } from './edit/field-types/combined-date-edit-field.component'; import { NgSelectModule } from '@ng-select/ng-select'; import { FormsModule } from '@angular/forms'; +import { ModalWithTurboContentDirective } from 'core-app/shared/components/fields/edit/modal-with-turbo-content/modal-with-turbo-content.directive'; @NgModule({ imports: [ @@ -169,6 +170,8 @@ import { FormsModule } from '@angular/forms'; AttributeLabelMacroComponent, WorkPackageQuickinfoMacroComponent, + + ModalWithTurboContentDirective, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], }) diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 00be06fa2270..2fd5edc8b056 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -67,12 +67,6 @@ import { CopyToClipboardService } from './components/copy-to-clipboard/copy-to-c import { CopyToClipboardComponent } from './components/copy-to-clipboard/copy-to-clipboard.component'; import { OpDateTimeComponent } from './components/date/op-date-time.component'; import { ToastComponent } from './components/toaster/toast.component'; - -// Old datepickers -import { - OpMultiDatePickerComponent, -} from 'core-app/shared/components/datepicker/multi-date-picker/multi-date-picker.component'; - import { ToastsContainerComponent } from './components/toaster/toasts-container.component'; import { UploadProgressComponent } from './components/toaster/upload-progress.component'; import { ResizerComponent } from './components/resizer/resizer.component'; @@ -197,9 +191,6 @@ export function bootstrapModule(injector:Injector):void { OpProjectIncludeListComponent, OpLoadingProjectListComponent, - // Old datepickers - OpMultiDatePickerComponent, - OpNonWorkingDaysListComponent, ], providers: [ @@ -251,9 +242,6 @@ export function bootstrapModule(injector:Injector):void { OpNonWorkingDaysListComponent, - // Old datepickers - OpMultiDatePickerComponent, - ShareUpsaleComponent, ], }) diff --git a/frontend/src/global_styles/content/_work_packages.sass b/frontend/src/global_styles/content/_work_packages.sass index 74429a4b1be6..89faf9f14e74 100644 --- a/frontend/src/global_styles/content/_work_packages.sass +++ b/frontend/src/global_styles/content/_work_packages.sass @@ -71,3 +71,5 @@ // Resizer @import work_packages/resizer/resizer +// Generic Datepicker modal styles +@import work_packages/datepicker_modal diff --git a/frontend/src/global_styles/content/work_packages/_datepicker_modal.sass b/frontend/src/global_styles/content/work_packages/_datepicker_modal.sass new file mode 100644 index 000000000000..f02149124f6e --- /dev/null +++ b/frontend/src/global_styles/content/work_packages/_datepicker_modal.sass @@ -0,0 +1,35 @@ +.op-datepicker-modal + display: flex + flex-direction: column + height: 100% + + @media #{$spot-mq-drop-modal-in-context} + height: unset + min-height: 475px // Avoid jump in height when the calendar has 6 rows to display all weeks + width: 100vw + // Basically the width of the two calendars next to each other + spacings + // will be overwritten on mobile + max-width: 320px + + &--date-field + &_current, + &_current:hover + border: 2px solid var(--control-checked-color) !important + + &--flatpickr-instance.inline + margin: 0.5rem auto 0 auto !important + overflow: hidden + +@media screen and (max-width: $breakpoint-sm) + .op-datepicker-modal + // Use same width as spot-modal mobile + width: calc(100vw - 2rem) + margin-bottom: 0 + + &--flatpickr-instance + align-self: center + +.flatpickr-wrapper + // Make flatpickr behave correctly when it is instantiated + // inside a dialog using the static: true option. + width: 100% diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index b458b00a2e9d..ae226ff0cffa 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -50,6 +50,7 @@ page-header, sub-header, .op-work-package-details-tab-component, +.UnderlineNav, .tabnav, .Box-header, action-menu anchored-position diff --git a/frontend/src/stimulus/controllers/dynamic/work-packages/date-picker/preview.controller.ts b/frontend/src/stimulus/controllers/dynamic/work-packages/date-picker/preview.controller.ts new file mode 100644 index 000000000000..9ca48678975f --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/work-packages/date-picker/preview.controller.ts @@ -0,0 +1,119 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ + +import { DialogPreviewController } from '../dialog/preview.controller'; +import { TimezoneService } from 'core-app/core/datetime/timezone.service'; + +export default class PreviewController extends DialogPreviewController { + private timezoneService:TimezoneService; + + async connect() { + super.connect(); + + const context = await window.OpenProject.getPluginContext(); + this.timezoneService = context.services.timezone; + } + + markFieldAsTouched(event:{ target:HTMLInputElement }) { + super.markFieldAsTouched(event); + } + + // Ensures that on create forms, there is an "id" for the un-persisted + // work package when sending requests to the edit action for previews. + ensureValidPathname(formAction:string):string { + const wpPath = new URL(formAction); + + if (wpPath.pathname.endsWith('/work_packages/datepicker_dialog_content')) { + // Replace /work_packages/date_picker with /work_packages/new/date_picker + wpPath.pathname = wpPath.pathname.replace('/work_packages/datepicker_dialog_content', '/work_packages/new/datepicker_dialog_content'); + } + + return wpPath.toString(); + } + + ensureValidWpAction(wpPath:string):string { + return wpPath.endsWith('/work_packages/new/datepicker_dialog_content') ? 'new' : 'edit'; + } + + dispatchChangeEvent(field:HTMLInputElement) { + document.dispatchEvent( + new CustomEvent('date-picker:input-changed', { + detail: { + field: field.name, + value: this.getValueFor(field), + }, + }), + ); + } + + private getValueFor(field:HTMLInputElement):string { + if (field.type === 'checkbox') { + return field.checked.toString(); + } + + return field.value; + } + + highlightField(e:Event) { + const newHighlightedField = e.target; + if (newHighlightedField) { + Array.from(document.getElementsByClassName('op-datepicker-modal--date-field_current')).forEach( + (el) => { + el.classList.remove('op-datepicker-modal--date-field_current'); + el.removeAttribute('data-qa-highlighted'); + }, + ); + + (newHighlightedField as HTMLInputElement).classList.add('op-datepicker-modal--date-field_current'); + (newHighlightedField as HTMLInputElement).dataset.qaHighlighted = 'true'; + + document.dispatchEvent( + new CustomEvent('date-picker:input-focused', { + detail: { + field: (newHighlightedField as HTMLInputElement).name, + }, + }), + ); + } + } + + setTodayForField(event:unknown) { + (event as Event).preventDefault(); + + const targetFieldID = (event as { params:{ fieldReference:string } }).params.fieldReference; + if (targetFieldID) { + const inputField = document.getElementById(targetFieldID); + if (inputField) { + (inputField as HTMLInputElement).value = this.timezoneService.formattedISODate(Date.now()); + inputField.dispatchEvent(new Event('input')); + } + } + } +} diff --git a/frontend/src/stimulus/controllers/dynamic/work-packages/dialog/preview.controller.ts b/frontend/src/stimulus/controllers/dynamic/work-packages/dialog/preview.controller.ts new file mode 100644 index 000000000000..707f96186351 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/work-packages/dialog/preview.controller.ts @@ -0,0 +1,248 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ + +import { Controller } from '@hotwired/stimulus'; +import { + debounce, + DebouncedFunc, +} from 'lodash'; +import Idiomorph from 'idiomorph/dist/idiomorph.cjs'; + +interface TurboBeforeFrameRenderEventDetail { + render:(currentElement:HTMLElement, newElement:HTMLElement) => void; +} + +interface HTMLTurboFrameElement extends HTMLElement { + src:string; +} + +export abstract class DialogPreviewController extends Controller { + static targets = [ + 'form', + 'fieldInput', + 'initialValueInput', + 'touchedFieldInput', + ]; + + declare readonly fieldInputTargets:HTMLInputElement[]; + declare readonly formTarget:HTMLFormElement; + declare readonly initialValueInputTargets:HTMLInputElement[]; + declare readonly touchedFieldInputTargets:HTMLInputElement[]; + + private debouncedPreview:DebouncedFunc<(event:Event) => void>; + private frameMorphRenderer:(event:CustomEvent) => void; + private targetFieldName:string; + private touchedFields:Set; + + connect() { + this.touchedFields = new Set(); + this.touchedFieldInputTargets.forEach((input) => { + const fieldName = input.dataset.referrerField; + if (fieldName && input.value === 'true') { + this.touchedFields.add(fieldName); + } + }); + + // if the debounce value is changed, the following test helper must be kept + // in sync: `spec/support/edit_fields/progress_edit_field.rb`, method `#wait_for_preview_to_complete` + this.debouncedPreview = debounce((event:Event) => { void this.preview(event); }, 200); + + // Turbo supports morphing, by adding the + // attribute. However, it does not work that well with primer input: when + // adding "data-turbo-permanent" to keep value and focus on the active + // element, it also keeps the `aria-describedby` attribute which references + // caption and validation element ids. As these elements are morphed and get + // new ids, the ids referenced by `aria-describedby` are stale. This makes + // caption and validation message unaccessible for screen readers and other + // assistive technologies. This is why morph cannot be used here. + this.frameMorphRenderer = (event:CustomEvent) => { + event.detail.render = (currentElement:HTMLElement, newElement:HTMLElement) => { + Idiomorph.morph(currentElement, newElement, { + ignoreActiveValue: true, + callbacks: { + beforeNodeMorphed: (oldNode:Element) => { + // In case the element is an OpenProject custom dom element, morphing is prevented. + return !oldNode.tagName?.startsWith('OPCE-'); + }, + afterNodeMorphed: (oldNode:Element, newNode:Element) => { + if (newNode.tagName === 'INPUT' && (newNode as HTMLInputElement).name && (newNode as HTMLInputElement).name.startsWith('work_package[')) { + this.dispatchChangeEvent((newNode as HTMLInputElement)); + } + }, + }, + }); + }; + }; + + this.fieldInputTargets.forEach((target) => { + if (target.tagName.toLowerCase() === 'select') { + target.addEventListener('change', this.debouncedPreview); + } else { + target.addEventListener('input', this.debouncedPreview); + } + + if (target.dataset.focus === 'true') { + this.focusAndSetCursorPositionToEndOfInput(target); + } + }); + + const turboFrame = this.formTarget.closest('turbo-frame') as HTMLTurboFrameElement; + turboFrame.addEventListener('turbo:before-frame-render', this.frameMorphRenderer); + } + + disconnect() { + this.debouncedPreview.cancel(); + this.fieldInputTargets.forEach((target) => { + if (target.tagName.toLowerCase() === 'select') { + target.removeEventListener('change', this.debouncedPreview); + } else { + target.removeEventListener('input', this.debouncedPreview); + } + }); + const turboFrame = this.formTarget.closest('turbo-frame') as HTMLTurboFrameElement; + if (turboFrame) { + turboFrame.removeEventListener('turbo:before-frame-render', this.frameMorphRenderer); + } + } + + protected cancel():void { + document.dispatchEvent(new CustomEvent('cancelModalWithTurboContent')); + } + + markFieldAsTouched(event:{ target:HTMLInputElement }) { + this.targetFieldName = event.target.name.replace(/^work_package\[([^\]]+)\]$/, '$1'); + this.markTouched(this.targetFieldName); + } + + async preview(event:Event) { + let field:HTMLInputElement; + if (event.type === 'blur') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + field = (event as FocusEvent).relatedTarget as HTMLInputElement; + } else { + field = event.target as HTMLInputElement; + } + + const form = this.formTarget; + const formData = new FormData(form) as unknown as undefined; + const formParams = new URLSearchParams(formData); + + const wpParams = Array.from(formParams.entries()) + .filter(([key, _]) => key.startsWith('work_package')); + wpParams.push(['field', field?.name ?? '']); + + const wpPath = this.ensureValidPathname(form.action); + const wpAction = this.ensureValidWpAction(wpPath); + + const editUrl = `${wpPath}/${wpAction}?${new URLSearchParams(wpParams).toString()}`; + const turboFrame = this.formTarget.closest('turbo-frame') as HTMLTurboFrameElement; + + if (turboFrame) { + turboFrame.src = editUrl; + } + } + + private focusAndSetCursorPositionToEndOfInput(field:HTMLInputElement) { + field.focus(); + field.setSelectionRange( + field.value.length, + field.value.length, + ); + } + + abstract ensureValidPathname(formAction:string):string; + + abstract ensureValidWpAction(path:string):string; + + abstract dispatchChangeEvent(field:HTMLInputElement|null):void; + + protected isBeingEdited(fieldName:string) { + return fieldName === this.targetFieldName; + } + + // Finds the hidden initial value input based on a field name. + // + // The initial value input field holds the initial value of the work package + // before being set by the user or derived. + private findInitialValueInput(fieldName:string):HTMLInputElement|undefined { + return this.initialValueInputTargets.find((input) => + (input.dataset.referrerField === fieldName)); + } + + // Finds the value field input based on a field name. + // + // The value field input holds the current value of a field. + protected findValueInput(fieldName:string):HTMLInputElement|undefined { + return this.fieldInputTargets.find((input) => + (input.name === fieldName) || (input.name === `work_package[${fieldName}]`)); + } + + protected isTouchedAndEmpty(fieldName:string):boolean { + return this.isTouched(fieldName) && this.isValueEmpty(fieldName); + } + + protected isTouched(fieldName:string):boolean { + return this.touchedFields.has(fieldName); + } + + protected isInitialValueEmpty(fieldName:string):boolean { + const valueInput = this.findInitialValueInput(fieldName); + return valueInput?.value === ''; + } + + protected isValueEmpty(fieldName:string):boolean { + const valueInput = this.findValueInput(fieldName); + return valueInput?.value === ''; + } + + protected isValueSet(fieldName:string):boolean { + const valueInput = this.findValueInput(fieldName); + return valueInput !== undefined && valueInput.value !== ''; + } + + protected markTouched(fieldName:string):void { + this.touchedFields.add(fieldName); + this.updateTouchedFieldHiddenInputs(); + } + + protected markUntouched(fieldName:string):void { + this.touchedFields.delete(fieldName); + this.updateTouchedFieldHiddenInputs(); + } + + private updateTouchedFieldHiddenInputs():void { + this.touchedFieldInputTargets.forEach((input) => { + const fieldName = input.dataset.referrerField; + if (fieldName) { + input.value = this.isTouched(fieldName) ? 'true' : 'false'; + } + }); + } +} diff --git a/frontend/src/stimulus/controllers/dynamic/work-packages/progress/preview.controller.ts b/frontend/src/stimulus/controllers/dynamic/work-packages/progress/preview.controller.ts index 61b253b88b7d..32c4e625e25e 100644 --- a/frontend/src/stimulus/controllers/dynamic/work-packages/progress/preview.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/work-packages/progress/preview.controller.ts @@ -28,142 +28,20 @@ * ++ */ -import { Controller } from '@hotwired/stimulus'; -import { debounce, DebouncedFunc } from 'lodash'; -import Idiomorph from 'idiomorph/dist/idiomorph.cjs'; - -interface TurboBeforeFrameRenderEventDetail { - render:(currentElement:HTMLElement, newElement:HTMLElement) => void; -} - -interface HTMLTurboFrameElement extends HTMLElement { - src:string; -} - -export default class PreviewController extends Controller { - static targets = [ - 'form', - 'progressInput', - 'initialValueInput', - 'touchedFieldInput', - ]; - - declare readonly progressInputTargets:HTMLInputElement[]; - declare readonly formTarget:HTMLFormElement; - declare readonly initialValueInputTargets:HTMLInputElement[]; - declare readonly touchedFieldInputTargets:HTMLInputElement[]; - - private debouncedPreview:DebouncedFunc<(event:Event) => void>; - private frameMorphRenderer:(event:CustomEvent) => void; - private targetFieldName:string; - private touchedFields:Set; - - connect() { - this.touchedFields = new Set(); - this.touchedFieldInputTargets.forEach((input) => { - const fieldName = input.dataset.referrerField; - if (fieldName && input.value === 'true') { - this.touchedFields.add(fieldName); - } - }); - - this.debouncedPreview = debounce((event:Event) => { void this.preview(event); }, 100); - - // Turbo supports morphing, by adding the - // attribute. However, it does not work that well with primer input: when - // adding "data-turbo-permanent" to keep value and focus on the active - // element, it also keeps the `aria-describedby` attribute which references - // caption and validation element ids. As these elements are morphed and get - // new ids, the ids referenced by `aria-describedby` are stale. This makes - // caption and validation message unaccessible for screen readers and other - // assistive technologies. This is why morph cannot be used here. - this.frameMorphRenderer = (event:CustomEvent) => { - event.detail.render = (currentElement:HTMLElement, newElement:HTMLElement) => { - Idiomorph.morph(currentElement, newElement, { ignoreActiveValue: true }); - }; - }; - - this.progressInputTargets.forEach((target) => { - if (target.tagName.toLowerCase() === 'select') { - target.addEventListener('change', this.debouncedPreview); - } else { - target.addEventListener('input', this.debouncedPreview); - } - target.addEventListener('blur', this.debouncedPreview); - - if (target.dataset.focus === 'true') { - this.focusAndSetCursorPositionToEndOfInput(target); - } - }); - - const turboFrame = this.formTarget.closest('turbo-frame') as HTMLTurboFrameElement; - turboFrame.addEventListener('turbo:before-frame-render', this.frameMorphRenderer); - } - - disconnect() { - this.debouncedPreview.cancel(); - this.progressInputTargets.forEach((target) => { - if (target.tagName.toLowerCase() === 'select') { - target.removeEventListener('change', this.debouncedPreview); - } else { - target.removeEventListener('input', this.debouncedPreview); - } - target.removeEventListener('blur', this.debouncedPreview); - }); - const turboFrame = this.formTarget.closest('turbo-frame') as HTMLTurboFrameElement; - if (turboFrame) { - turboFrame.removeEventListener('turbo:before-frame-render', this.frameMorphRenderer); - } - } +import { DialogPreviewController } from '../dialog/preview.controller'; +export default class PreviewController extends DialogPreviewController { markFieldAsTouched(event:{ target:HTMLInputElement }) { - this.targetFieldName = event.target.name.replace(/^work_package\[([^\]]+)\]$/, '$1'); - this.markTouched(this.targetFieldName); + super.markFieldAsTouched(event); if (this.isWorkBasedMode()) { this.keepWorkValue(); } } - async preview(event:Event) { - let field:HTMLInputElement; - if (event.type === 'blur') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - field = (event as FocusEvent).relatedTarget as HTMLInputElement; - } else { - field = event.target as HTMLInputElement; - } - - const form = this.formTarget; - const formData = new FormData(form) as unknown as undefined; - const formParams = new URLSearchParams(formData); - - const wpParams = Array.from(formParams.entries()) - .filter(([key, _]) => key.startsWith('work_package')); - wpParams.push(['field', field?.name ?? '']); - - const wpPath = this.ensureValidPathname(form.action); - const wpAction = wpPath.endsWith('/work_packages/new/progress') ? 'new' : 'edit'; - - const editUrl = `${wpPath}/${wpAction}?${new URLSearchParams(wpParams).toString()}`; - const turboFrame = this.formTarget.closest('turbo-frame') as HTMLTurboFrameElement; - - if (turboFrame) { - turboFrame.src = editUrl; - } - } - - private focusAndSetCursorPositionToEndOfInput(field:HTMLInputElement) { - field.focus(); - field.setSelectionRange( - field.value.length, - field.value.length, - ); - } - // Ensures that on create forms, there is an "id" for the un-persisted // work package when sending requests to the edit action for previews. - private ensureValidPathname(formAction:string):string { + ensureValidPathname(formAction:string):string { const wpPath = new URL(formAction); if (wpPath.pathname.endsWith('/work_packages/progress')) { @@ -174,126 +52,70 @@ export default class PreviewController extends Controller { return wpPath.toString(); } + ensureValidWpAction(wpPath:string):string { + return wpPath.endsWith('/work_packages/new/progress') ? 'new' : 'edit'; + } + + // Inheritance compliance + dispatchChangeEvent() {} + private isWorkBasedMode() { - return this.findValueInput('done_ratio') !== undefined; + return super.findValueInput('done_ratio') !== undefined; } private keepWorkValue() { - if (this.isInitialValueEmpty('estimated_hours') && !this.isTouched('estimated_hours')) { + if (super.isInitialValueEmpty('estimated_hours') && !super.isTouched('estimated_hours')) { // let work be derived return; } - if (this.isBeingEdited('estimated_hours')) { + if (super.isBeingEdited('estimated_hours')) { this.untouchFieldsWhenWorkIsEdited(); - } else if (this.isBeingEdited('remaining_hours')) { + } else if (super.isBeingEdited('remaining_hours')) { this.untouchFieldsWhenRemainingWorkIsEdited(); - } else if (this.isBeingEdited('done_ratio')) { + } else if (super.isBeingEdited('done_ratio')) { this.untouchFieldsWhenPercentCompleteIsEdited(); } } private untouchFieldsWhenWorkIsEdited() { if (this.areBothTouched('remaining_hours', 'done_ratio')) { - if (this.isValueEmpty('done_ratio') && this.isValueEmpty('remaining_hours')) { + if (super.isValueEmpty('done_ratio') && super.isValueEmpty('remaining_hours')) { return; } - if (this.isValueEmpty('done_ratio')) { - this.markUntouched('done_ratio'); + if (super.isValueEmpty('done_ratio')) { + super.markUntouched('done_ratio'); } else { - this.markUntouched('remaining_hours'); + super.markUntouched('remaining_hours'); } - } else if (this.isTouchedAndEmpty('remaining_hours') && this.isValueSet('done_ratio')) { + } else if (super.isTouchedAndEmpty('remaining_hours') && super.isValueSet('done_ratio')) { // force remaining work derivation - this.markUntouched('remaining_hours'); - this.markTouched('done_ratio'); - } else if (this.isTouchedAndEmpty('done_ratio') && this.isValueSet('remaining_hours')) { + super.markUntouched('remaining_hours'); + super.markTouched('done_ratio'); + } else if (super.isTouchedAndEmpty('done_ratio') && super.isValueSet('remaining_hours')) { // force % complete derivation - this.markUntouched('done_ratio'); - this.markTouched('remaining_hours'); + super.markUntouched('done_ratio'); + super.markTouched('remaining_hours'); } } - private untouchFieldsWhenRemainingWorkIsEdited() { - if (this.isTouchedAndEmpty('estimated_hours') && this.isValueSet('done_ratio')) { + private untouchFieldsWhenRemainingWorkIsEdited():void { + if (super.isTouchedAndEmpty('estimated_hours') && super.isValueSet('done_ratio')) { // force work derivation - this.markUntouched('estimated_hours'); - this.markTouched('done_ratio'); - } else if (this.isValueSet('estimated_hours')) { - this.markUntouched('done_ratio'); + super.markUntouched('estimated_hours'); + super.markTouched('done_ratio'); + } else if (super.isValueSet('estimated_hours')) { + super.markUntouched('done_ratio'); } } - private untouchFieldsWhenPercentCompleteIsEdited() { - if (this.isValueSet('estimated_hours')) { - this.markUntouched('remaining_hours'); + private untouchFieldsWhenPercentCompleteIsEdited():void { + if (super.isValueSet('estimated_hours')) { + super.markUntouched('remaining_hours'); } } - private areBothTouched(fieldName1:string, fieldName2:string) { - return this.isTouched(fieldName1) && this.isTouched(fieldName2); - } - - private isBeingEdited(fieldName:string) { - return fieldName === this.targetFieldName; - } - - // Finds the hidden initial value input based on a field name. - // - // The initial value input field holds the initial value of the work package - // before being set by the user or derived. - private findInitialValueInput(fieldName:string):HTMLInputElement|undefined { - return this.initialValueInputTargets.find((input) => - (input.dataset.referrerField === fieldName)); - } - - // Finds the value field input based on a field name. - // - // The value field input holds the current value of a progress field. - private findValueInput(fieldName:string):HTMLInputElement|undefined { - return this.progressInputTargets.find((input) => - (input.name === fieldName) || (input.name === `work_package[${fieldName}]`)); - } - - private isTouchedAndEmpty(fieldName:string) { - return this.isTouched(fieldName) && this.isValueEmpty(fieldName); - } - - private isTouched(fieldName:string) { - return this.touchedFields.has(fieldName); - } - - private isInitialValueEmpty(fieldName:string) { - const valueInput = this.findInitialValueInput(fieldName); - return valueInput?.value === ''; - } - - private isValueEmpty(fieldName:string) { - const valueInput = this.findValueInput(fieldName); - return valueInput?.value === ''; - } - - private isValueSet(fieldName:string) { - const valueInput = this.findValueInput(fieldName); - return valueInput !== undefined && valueInput.value !== ''; - } - - private markTouched(fieldName:string) { - this.touchedFields.add(fieldName); - this.updateTouchedFieldHiddenInputs(); - } - - private markUntouched(fieldName:string) { - this.touchedFields.delete(fieldName); - this.updateTouchedFieldHiddenInputs(); - } - - private updateTouchedFieldHiddenInputs() { - this.touchedFieldInputTargets.forEach((input) => { - const fieldName = input.dataset.referrerField; - if (fieldName) { - input.value = this.isTouched(fieldName) ? 'true' : 'false'; - } - }); + private areBothTouched(fieldName1:string, fieldName2:string):boolean { + return super.isTouched(fieldName1) && super.isTouched(fieldName2); } } diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts index 022ec08d973b..633f29be8c7a 100644 --- a/frontend/src/stimulus/setup.ts +++ b/frontend/src/stimulus/setup.ts @@ -14,6 +14,7 @@ import OpShowWhenValueSelectedController from './controllers/show-when-value-sel import FlashController from './controllers/flash.controller'; import OpProjectsZenModeController from './controllers/dynamic/projects/zen-mode.controller'; import PasswordConfirmationDialogController from './controllers/password-confirmation-dialog.controller'; +import PreviewController from './controllers/dynamic/work-packages/date-picker/preview.controller'; declare global { interface Window { @@ -43,3 +44,4 @@ instance.register('show-when-checked', OpShowWhenCheckedController); instance.register('show-when-value-selected', OpShowWhenValueSelectedController); instance.register('table-highlighting', TableHighlightingController); instance.register('projects-zen-mode', OpProjectsZenModeController); +instance.register('work-packages--date-picker--preview', PreviewController); diff --git a/lib/primer/open_project/forms/dsl/single_date_picker_input.rb b/lib/primer/open_project/forms/dsl/single_date_picker_input.rb index 7a356bdfba4c..1337102b5712 100644 --- a/lib/primer/open_project/forms/dsl/single_date_picker_input.rb +++ b/lib/primer/open_project/forms/dsl/single_date_picker_input.rb @@ -43,7 +43,7 @@ def initialize(name:, label:, datepicker_options:, **system_arguments) def derive_datepicker_options(options) options.reverse_merge( - component: "opce-single-date-picker" + component: "opce-basic-single-date-picker" ) end diff --git a/spec/features/work_packages/datepicker/datepicker_logic_spec.rb b/spec/features/work_packages/datepicker/datepicker_logic_spec.rb index f35f44a3c2fd..5b8ba682e28a 100644 --- a/spec/features/work_packages/datepicker/datepicker_logic_spec.rb +++ b/spec/features/work_packages/datepicker/datepicker_logic_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -230,7 +232,9 @@ def apply_and_expect_saved(attributes) end end - describe "when all values set, removing duration through icon (scenario 6a)" do + describe "when all values set, removing duration through icon (scenario 6a)", + skip: "TODO: the duration field no longer has a 'x' icon to clear the field. " \ + "If it's not to be added back, we should remove this test." do let(:current_attributes) do { start_date: Date.parse("2021-02-09"), @@ -244,7 +248,7 @@ def apply_and_expect_saved(attributes) datepicker.expect_due_date "2021-02-12" datepicker.expect_duration 3 - datepicker.clear_duration_with_icon + datepicker.clear_duration datepicker.expect_start_date "2021-02-09" datepicker.expect_due_date "" @@ -266,7 +270,7 @@ def apply_and_expect_saved(attributes) datepicker.expect_due_date "2021-02-12" datepicker.expect_duration 3 - datepicker.clear_duration_with_icon + datepicker.clear_duration datepicker.expect_start_date "2021-02-09" datepicker.expect_due_date "" @@ -279,7 +283,7 @@ def apply_and_expect_saved(attributes) datepicker.expect_due_date "2021-02-09" datepicker.expect_duration 3 - datepicker.clear_duration_with_icon + datepicker.clear_duration datepicker.expect_start_date "2021-02-05" datepicker.expect_due_date "" diff --git a/spec/features/work_packages/details/date_editor_spec.rb b/spec/features/work_packages/details/date_editor_spec.rb index ca273c164bf4..3df939b62065 100644 --- a/spec/features/work_packages/details/date_editor_spec.rb +++ b/spec/features/work_packages/details/date_editor_spec.rb @@ -64,6 +64,7 @@ work_packages_page.visit! work_packages_page.ensure_page_loaded + wait_for_network_idle end it "can directly set the due date when only a start date is set" do @@ -139,6 +140,9 @@ start_date.datepicker.expect_start_date "2016-01-03" + # The inputs have a debounce which we have to wait for before clicking the next field + sleep 0.25 + # Since the focus shifts automatically, we can directly click again to modify the end date start_date.datepicker.select_day "21" @@ -216,7 +220,7 @@ start_date.set_due_date Time.zone.today # Wait for duration to be derived - start_date.expect_duration /\d+ days/ + start_date.expect_duration /\d+/ # As the to be selected date is automatically toggled, # we can directly set the start date afterwards to the same day @@ -270,9 +274,10 @@ work_packages_page.accept_alert_dialog! if work_packages_page.has_alert_dialog? # Ensure no modal survives - expect(page).to have_no_css(".spot-modal") + expect(page).to have_no_css(".spot-drop-modal--body") end + # rubocop:disable Layout/LineLength context "with a date custom field" do let(:cf_field) { EditField.new page, date_cf.attribute_name(:camel_case) } let(:datepicker) { Components::BasicDatepicker.new } @@ -352,17 +357,16 @@ let(:schedule_manually) { true } it "shows a banner that the relations are ignored" do - expect(page).to have_css("#{test_selector('op-modal-banner-warning')} span", - text: "Manual scheduling enabled, all relations ignored.", + expect(page).to have_css(test_selector("op-modal-banner-warning").to_s, + text: "Manually scheduled. Dates not affected by relations. Click on “Show relations” for Gantt overview.", wait: 5) # When toggling manually scheduled start_date.toggle_scheduling_mode - # Expect new banner info - expect(page) - .to have_css("#{test_selector('op-modal-banner-warning')} span", - text: "Changing these dates will affect dates of related work packages.") + # Expect no banner as it is not automatically schedulable + expect(page).not_to have_test_selector("op-modal-banner-warning") + expect(page).not_to have_test_selector("op-modal-banner-info") new_window = window_opened_by { click_on "Show relations" } switch_to_window new_window @@ -378,17 +382,15 @@ context "when work package is not manually scheduled" do let(:schedule_manually) { false } - it "shows a banner that the start date is limited" do - expect(page) - .to have_css("#{test_selector('op-modal-banner-warning')} span", - text: "Changing these dates will affect dates of related work packages.", - wait: 5) + it "shows no banner as the WP is not automatically savable without children or predecessor" do + expect(page).not_to have_test_selector("op-modal-banner-warning") + expect(page).not_to have_test_selector("op-modal-banner-info") # When toggling manually scheduled start_date.toggle_scheduling_mode - expect(page).to have_css("#{test_selector('op-modal-banner-warning')} span", - text: "Manual scheduling enabled, all relations ignored.") + expect(page).to have_css(test_selector("op-modal-banner-warning").to_s, + text: "Manually scheduled. Dates not affected by relations. Click on “Show relations” for Gantt overview.") end end end @@ -410,15 +412,15 @@ let(:schedule_manually) { true } it "shows a banner that the relations are ignored" do - expect(page).to have_css("#{test_selector('op-modal-banner-warning')} span", - text: "Manual scheduling enabled, all relations ignored.") + expect(page).to have_css(test_selector("op-modal-banner-warning").to_s, + text: "Manually scheduled. Dates not affected by relations. This has child work packages but their start dates are ignored.") # When toggling manually scheduled start_date.toggle_scheduling_mode # Expect banner to switch - expect(page).to have_css("#{test_selector('op-modal-banner-info')} span", - text: "Automatically scheduled. Dates are derived from relations.") + expect(page).to have_css(test_selector("op-modal-banner-info").to_s, + text: "The dates are determined by child work packages. Click on “Show relations” for Gantt overview.") new_window = window_opened_by { click_on "Show relations" } switch_to_window new_window @@ -432,15 +434,15 @@ context "when parent is not manually scheduled" do let(:schedule_manually) { false } - it "shows a banner that the dates are not editable" do - expect(page).to have_css("#{test_selector('op-modal-banner-info')} span", - text: "Automatically scheduled. Dates are derived from relations.") + it "shows a banner that the dates are are determined by the child" do + expect(page).to have_css(test_selector("op-modal-banner-info").to_s, + text: "The dates are determined by child work packages. Click on “Show relations” for Gantt overview.") # When toggling manually scheduled start_date.toggle_scheduling_mode - expect(page).to have_css("#{test_selector('op-modal-banner-warning')} span", - text: "Manual scheduling enabled, all relations ignored.") + expect(page).to have_css(test_selector("op-modal-banner-warning").to_s, + text: "Manually scheduled. Dates not affected by relations. This has child work packages but their start dates are ignored.") new_window = window_opened_by { click_on "Show relations" } switch_to_window new_window @@ -460,8 +462,8 @@ end it "allows switching to manual scheduling to set the ignore NWD (Regression #43933)" do - expect(page).to have_css("#{test_selector('op-modal-banner-info')} span", - text: "Automatically scheduled. Dates are derived from relations.") + expect(page).to have_css(test_selector("op-modal-banner-info").to_s, + text: "The dates are determined by child work packages. Click on “Show relations” for Gantt overview.") # Expect "Working days only" to be checked datepicker.expect_ignore_non_working_days_disabled @@ -473,16 +475,16 @@ datepicker.toggle_ignore_non_working_days datepicker.expect_ignore_non_working_days true - expect(page).to have_css("#{test_selector('op-modal-banner-warning')} span", - text: "Manual scheduling enabled, all relations ignored.") + expect(page).to have_css(test_selector("op-modal-banner-warning").to_s, + text: "Manually scheduled. Dates not affected by relations. This has child work packages but their start dates are ignored.") # Reset when disabled start_date.toggle_scheduling_mode datepicker.expect_ignore_non_working_days_disabled datepicker.expect_ignore_non_working_days false, disabled: true - expect(page).to have_css("#{test_selector('op-modal-banner-info')} span", - text: "Automatically scheduled. Dates are derived from relations.") + expect(page).to have_css(test_selector("op-modal-banner-info").to_s, + text: "The dates are determined by child work packages. Click on “Show relations” for Gantt overview.") end end end @@ -508,15 +510,15 @@ let(:schedule_manually) { true } it "shows a banner that the relations are ignored" do - expect(page).to have_css("#{test_selector('op-modal-banner-warning')} span", - text: "Manual scheduling enabled, all relations ignored.") + expect(page).to have_css(test_selector("op-modal-banner-warning").to_s, + text: "Manually scheduled. Dates not affected by relations. Click on “Show relations” for Gantt overview.") # When toggling manually scheduled start_date.toggle_scheduling_mode # Expect new banner info - expect(page).to have_css("#{test_selector('op-modal-banner-info')} span", - text: "Available start and finish dates are limited by relations.") + expect(page).to have_css(test_selector("op-modal-banner-info").to_s, + text: "The start date is set by a predecessor. Click on “Show relations” for Gantt overview.") new_window = window_opened_by { click_on "Show relations" } switch_to_window new_window @@ -530,15 +532,15 @@ context "when work package is not manually scheduled" do let(:schedule_manually) { false } - it "shows a banner that the start date is limited" do - expect(page).to have_css("#{test_selector('op-modal-banner-info')} span", - text: "Available start and finish dates are limited by relations.") + it "shows a banner that the start date it set by the predecessor" do + expect(page).to have_css(test_selector("op-modal-banner-info").to_s, + text: "The start date is set by a predecessor. Click on “Show relations” for Gantt overview.") # When toggling manually scheduled start_date.toggle_scheduling_mode - expect(page).to have_css("#{test_selector('op-modal-banner-warning')} span", - text: "Manual scheduling enabled, all relations ignored.") + expect(page).to have_css(test_selector("op-modal-banner-warning").to_s, + text: "Manually scheduled. Dates not affected by relations. Click on “Show relations” for Gantt overview.") end end end @@ -563,15 +565,15 @@ let(:schedule_manually) { true } it "shows a banner that the relations are ignored" do - expect(page).to have_css("#{test_selector('op-modal-banner-warning')} span", - text: "Manual scheduling enabled, all relations ignored.") + expect(page).to have_css(test_selector("op-modal-banner-warning").to_s, + text: "Manually scheduled. Dates not affected by relations. Click on “Show relations” for Gantt overview.") # When toggling manually scheduled start_date.toggle_scheduling_mode - expect(page) - .to have_css("#{test_selector('op-modal-banner-warning')} span", - text: "Changing these dates will affect dates of related work packages.") + # There is no banner + expect(page).not_to have_test_selector("op-modal-banner-warning") + expect(page).not_to have_test_selector("op-modal-banner-info") new_window = window_opened_by { click_on "Show relations" } switch_to_window new_window @@ -585,19 +587,20 @@ context "when work package is not manually scheduled" do let(:schedule_manually) { false } - it "shows a banner that the start date is limited" do - expect(page) - .to have_css("#{test_selector('op-modal-banner-warning')} span", - text: "Changing these dates will affect dates of related work packages.") + it "shows no banner as the WP is not automatically savable without children or predecessor" do + # There is no banner + expect(page).not_to have_test_selector("op-modal-banner-warning") + expect(page).not_to have_test_selector("op-modal-banner-info") # When toggling manually scheduled start_date.toggle_scheduling_mode - expect(page).to have_css("#{test_selector('op-modal-banner-warning')} span", - text: "Manual scheduling enabled, all relations ignored.") + expect(page).to have_css(test_selector("op-modal-banner-warning").to_s, + text: "Manually scheduled. Dates not affected by relations. Click on “Show relations” for Gantt overview.") end end end + # rubocop:enable Layout/LineLength context "with a negative time zone", driver: :chrome_new_york_time_zone do it "can normally select the dates via datepicker (regression #43562)" do diff --git a/spec/features/work_packages/table/duration_field_spec.rb b/spec/features/work_packages/table/duration_field_spec.rb index 38fc4268b96a..8d6d13fa21a9 100644 --- a/spec/features/work_packages/table/duration_field_spec.rb +++ b/spec/features/work_packages/table/duration_field_spec.rb @@ -35,9 +35,10 @@ it "shows the duration as days and opens the datepicker on click" do duration.expect_state_text "4 days" duration.activate! + wait_for_network_idle date_field.expect_duration_highlighted - expect(page).to have_focus_on("#{test_selector('op-datepicker-modal--duration-field')} input[name='duration']") - expect(page).to have_field("duration", with: "4", wait: 10) + expect(page).to have_focus_on(test_selector("op-datepicker-modal--duration-field").to_s) + expect(page).to have_field("work_package[duration]", with: "4", wait: 10) end end diff --git a/spec/features/work_packages/table/scheduling/manual_scheduling_spec.rb b/spec/features/work_packages/table/scheduling/manual_scheduling_spec.rb index bc60351eee80..025753a1b258 100644 --- a/spec/features/work_packages/table/scheduling/manual_scheduling_spec.rb +++ b/spec/features/work_packages/table/scheduling/manual_scheduling_spec.rb @@ -56,8 +56,8 @@ # Expect not editable start_date.within_modal do - expect(page).to have_css('input[name="startDate"][disabled]') - expect(page).to have_css('input[name="endDate"][disabled]') + expect(page).to have_css('input[name="work_package[start_date]"][disabled]') + expect(page).to have_css('input[name="work_package[due_date]"][disabled]') expect(page).to have_css("#{test_selector('op-datepicker-modal--action')}:not([disabled])", text: "Cancel") expect(page).to have_css("#{test_selector('op-datepicker-modal--action')}:not([disabled])", text: "Save") end @@ -66,8 +66,8 @@ # Expect editable start_date.within_modal do - expect(page).to have_css('input[name="startDate"]:not([disabled])') - expect(page).to have_css('input[name="endDate"]:not([disabled])') + expect(page).to have_css('input[name="work_package[start_date]"]:not([disabled])') + expect(page).to have_css('input[name="work_package[due_date]"]:not([disabled])') expect(page).to have_css("#{test_selector('op-datepicker-modal--action')}:not([disabled])", text: "Cancel") expect(page).to have_css("#{test_selector('op-datepicker-modal--action')}:not([disabled])", text: "Save") end @@ -90,8 +90,8 @@ # Expect not editable start_date.within_modal do - expect(page).to have_css("input[name=startDate][disabled]") - expect(page).to have_css("input[name=endDate][disabled]") + expect(page).to have_css('input[name="work_package[start_date]"][disabled]') + expect(page).to have_css('input[name="work_package[due_date]"][disabled]') expect(page).to have_css("#{test_selector('op-datepicker-modal--action')}:not([disabled])", text: "Cancel") expect(page).to have_css("#{test_selector('op-datepicker-modal--action')}:not([disabled])", text: "Save") end @@ -101,8 +101,8 @@ # Expect not editable start_date.within_modal do - fill_in "startDate", with: "2020-07-20" - fill_in "endDate", with: "2020-07-25" + fill_in "work_package[start_date]", with: "2020-07-20" + fill_in "work_package[due_date]", with: "2020-07-25" end # Wait for the debounce to be done diff --git a/spec/support/components/datepicker/datepicker.rb b/spec/support/components/datepicker/datepicker.rb index 01ef8d1f311b..a8e46495d799 100644 --- a/spec/support/components/datepicker/datepicker.rb +++ b/spec/support/components/datepicker/datepicker.rb @@ -39,16 +39,21 @@ def flatpickr_container ## # Clear all values def clear! - set_field(container.find_field("startDate"), "", wait_for_changes_to_be_applied: false) - set_field(container.find_field("endDate"), "", wait_for_changes_to_be_applied: false) + set_field(container.find_field("work_package[start_date]"), "", wait_for_changes_to_be_applied: false) + set_field(container.find_field("work_package[due_date]"), "", wait_for_changes_to_be_applied: false) end def expect_visible expect(container).to have_css(".flatpickr-calendar .flatpickr-current-month", wait: 10) + + # For whatever reason, the stimulus controller in the WorkPackage Datepicker needs some time to be loaded. + # So please, do not remove this line. + wait_for_network_idle end def expect_not_visible expect(container).to have_no_css(".flatpickr-calendar .flatpickr-current-month", wait: 10) + wait_for_network_idle end ## diff --git a/spec/support/components/datepicker/work_package_datepicker.rb b/spec/support/components/datepicker/work_package_datepicker.rb index a00c759ae77e..d731f125047d 100644 --- a/spec/support/components/datepicker/work_package_datepicker.rb +++ b/spec/support/components/datepicker/work_package_datepicker.rb @@ -20,28 +20,23 @@ def expect_month(month) ## # Expect duration def expect_duration(value) - value = - if value.is_a?(Regexp) - value - elsif value.nil? || value == "" - "" - else - I18n.t("js.units.day", count: value) - end + if value.nil? || value == "" + value = "" + end - expect(container).to have_field("duration", with: value, wait: 10) + expect(container).to have_field("work_package[duration]", with: value, wait: 10) end def milestone_date_field - container.find_field "date" + container.find_field "work_package[start_date]" end def start_date_field - container.find_field "startDate" + container.find_field "work_package[start_date]" end def due_date_field - container.find_field "endDate" + container.find_field "work_package[due_date]" end def focus_milestone_date @@ -59,19 +54,19 @@ def focus_due_date ## # Expect date (milestone type) def expect_milestone_date(value) - expect(container).to have_field("date", with: value, wait: 20) + expect(container).to have_field("work_package[start_date]", with: value, wait: 20) end ## # Expect start date def expect_start_date(value) - expect(container).to have_field("startDate", with: value, wait: 20) + expect(container).to have_field("work_package[start_date]", with: value, wait: 20) end ## # Expect due date def expect_due_date(value) - expect(container).to have_field("endDate", with: value, wait: 20) + expect(container).to have_field("work_package[due_date]", with: value, wait: 20) end def set_milestone_date(value) @@ -91,11 +86,11 @@ def expect_start_highlighted end def expect_due_highlighted - expect(container).to have_css('[data-test-selector="op-datepicker-modal--end-date-field"][data-qa-highlighted]') + expect(container).to have_css('[data-test-selector="op-datepicker-modal--due-date-field"][data-qa-highlighted]') end def duration_field - container.find_field "duration" + container.find_field "work_package[duration]" end def focus_duration @@ -103,17 +98,7 @@ def focus_duration end def set_today(date) - key = - case date.to_s - when "due" - "end" - else - date - end - - page.within("[data-test-selector='datepicker-#{key}-date']") do - find("button", text: "Today").click - end + page.find_test_selector("op-datepicker-modal--#{date}-date-field--today").click end def save!(text: I18n.t(:button_save)) @@ -140,38 +125,38 @@ def expect_scheduling_mode(manually) end def expect_manual_scheduling_mode - expect(container).to have_checked_field("scheduling", visible: :all) + expect(container) + .to have_css('[data-test-selector="op-datepicker-modal--scheduling_manual"][data-qa-selected="true"]') end def expect_automatic_scheduling_mode - expect(container).to have_unchecked_field("scheduling", visible: :all) + expect(container) + .to have_css('[data-test-selector="op-datepicker-modal--scheduling_automatic"][data-qa-selected="true"]') end def toggle_scheduling_mode - find("label", text: "Manual scheduling").click - end - - def scheduling_mode_input - container.find_field "scheduling", visible: :all - end - - def ignore_non_working_days_input - container.find_field "weekdays_only", visible: :all + page.within_test_selector "op-datepicker-modal--scheduling" do + page.find('[data-qa-selected="false"]').click + end end def expect_ignore_non_working_days_disabled - expect(container).to have_field("weekdays_only", disabled: true) + expect(container) + .to have_field("work_package[ignore_non_working_days]", disabled: true) end def expect_ignore_non_working_days_enabled - expect(container).to have_field("weekdays_only", disabled: false) + expect(container) + .to have_field("work_package[ignore_non_working_days]", disabled: false) end def expect_ignore_non_working_days(val, disabled: false) if val - expect(container).to have_unchecked_field("weekdays_only", disabled:) + expect(container) + .to have_field("work_package[ignore_non_working_days]", checked: false, disabled:) else - expect(container).to have_checked_field("weekdays_only", disabled:) + expect(container) + .to have_field("work_package[ignore_non_working_days]", checked: true, disabled:) end end @@ -182,13 +167,5 @@ def toggle_ignore_non_working_days def clear_duration set_duration("") end - - def clear_duration_with_icon - duration_field.click - - page - .find('[data-test-selector="op-datepicker-modal--duration-field"] .spot-text-field--clear-button') - .click - end end end diff --git a/spec/support/edit_fields/date_edit_field.rb b/spec/support/edit_fields/date_edit_field.rb index 0fb84dfa5b36..ff395de07b48 100644 --- a/spec/support/edit_fields/date_edit_field.rb +++ b/spec/support/edit_fields/date_edit_field.rb @@ -42,9 +42,9 @@ def modal_selector def input_selector if property_name == "combinedDate" - "input[name=startDate]" + "input[name='work_package[start_date]']" else - "input[name=#{property_name}]" + "input[name='work_package[#{property_name.underscore}]']" end end @@ -77,7 +77,7 @@ def activate_start_date_within_modal def activate_due_date_within_modal within_modal do - find('[data-test-selector="op-datepicker-modal--end-date-field"]').click + find('[data-test-selector="op-datepicker-modal--due-date-field"]').click end end @@ -111,6 +111,8 @@ def expect_active! expect(page) .to have_selector(modal_selector, wait: 10), "Expected date field '#{property_name}' to be active." + + wait_for_network_idle end def expect_inactive! diff --git a/spec/support/edit_fields/edit_field.rb b/spec/support/edit_fields/edit_field.rb index a07e627e94d2..9b091522d3e1 100644 --- a/spec/support/edit_fields/edit_field.rb +++ b/spec/support/edit_fields/edit_field.rb @@ -156,6 +156,7 @@ def expect_active! # Also ensure the element is not disabled expect_enabled! + wait_for_network_idle end def expect_inactive! diff --git a/spec/support/edit_fields/progress_edit_field.rb b/spec/support/edit_fields/progress_edit_field.rb index 7ce954d4c84e..17fc5e4e22d7 100644 --- a/spec/support/edit_fields/progress_edit_field.rb +++ b/spec/support/edit_fields/progress_edit_field.rb @@ -83,6 +83,7 @@ def active? end def clear + move_caret_to_end_of_input super(with_backspace: true) end @@ -110,15 +111,16 @@ def status_field? def focus return if focused? - input_element.click - input_element.click if status_field? # to close the dropdown + page.evaluate_script("arguments[0].focus()", input_element) wait_for_preview_to_complete end # Wait for the popover preview to be refreshed. # Preview occurs on field blur or change. def wait_for_preview_to_complete - sleep 0.110 # the preview on popover has a debounce of 100ms + # The preview on popover has a debounce that must be kept in sync here. + # See frontend/src/stimulus/controllers/dynamic/work-packages/dialog/preview.controller.ts + sleep 0.210 if using_cuprite? wait_for_network_idle # Wait for preview to finish end @@ -208,6 +210,10 @@ def cursor_at_end_of_input? input_element.evaluate_script("this.selectionStart == this.value.length;") end + def move_caret_to_end_of_input + page.evaluate_script("arguments[0].setSelectionRange(arguments[0].value.length, arguments[0].value.length)", input_element) + end + def expect_trigger_field_disabled expect(trigger_element).to be_disabled end