diff --git a/app/components/work_package_relations_tab/relation_component.rb b/app/components/work_package_relations_tab/relation_component.rb index 08e1452e02c5..88ebd16b7412 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..cc1ad5083ec5 --- /dev/null +++ b/app/components/work_packages/date_picker/banner_component.html.erb @@ -0,0 +1,11 @@ +<%= + render(Primer::Alpha::Banner.new(scheme:, + full: true, + icon: :info, + description:, + mb: 3, + classes: "rounded-top-2")) do |banner| + banner.with_action_button(tag: :a, href: link, target: "_blank") { I18n.t("work_packages.datepicker_modal.show_relations") } + title + end +%> diff --git a/app/components/work_packages/date_picker/banner_component.rb b/app/components/work_packages/date_picker/banner_component.rb new file mode 100644 index 000000000000..7a1d2f70626a --- /dev/null +++ b/app/components/work_packages/date_picker/banner_component.rb @@ -0,0 +1,122 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +# frozen_string_literal: true + +module WorkPackages + module DatePicker + class BannerComponent < ApplicationComponent + def initialize(work_package:, manually_scheduled: true) + super + + @work_package = work_package + @manually_scheduled = manually_scheduled + end + + private + + def scheme + @manually_scheduled ? :warning : :default + end + + def link + gantt_index_path( + query_props: { + c: %w[id subject type status assignee project startDate dueDate], + tll: '{"left":"startDate","right":"subject","farRight":null}', + tzl: "auto", + t: "id:asc", + tv: true, + hi: true, + f: [ + { "n" => "id", "o" => "=", "v" => all_relational_wp_ids } + ] + }.to_json.freeze + ) + end + + def title + if @manually_scheduled + I18n.t("work_packages.datepicker_modal.banner.title.manually_scheduled") + elsif children.any? + I18n.t("work_packages.datepicker_modal.banner.title.automatic_with_children") + elsif predecessor_relations.any? + I18n.t("work_packages.datepicker_modal.banner.title.automatic_with_predecessor") + end + end + + def description + if @manually_scheduled + if children.any? + return I18n.t("work_packages.datepicker_modal.banner.description.manual_with_children") + elsif predecessor_relations.any? + if overlapping_predecessor? + return I18n.t("work_packages.datepicker_modal.banner.description.manual_overlap_with_predecessors") + elsif predecessor_with_large_gap? + return I18n.t("work_packages.datepicker_modal.banner.description.manual_gap_between_predecessors") + end + end + end + + I18n.t("work_packages.datepicker_modal.banner.description.click_on_show_relations_to_open_gantt", + button_name: I18n.t("work_packages.datepicker_modal.show_relations")) + end + + def overlapping_predecessor? + predecessor_work_packages.any? { |wp| wp.due_date.after?(@work_package.start_date) } + end + + def predecessor_with_large_gap? + sorted = predecessor_work_packages.sort_by(&:due_date) + sorted.last.due_date.before?(@work_package.start_date - 2) + end + + def predecessor_relations + @predecessor_relations ||= @work_package.follows_relations + end + + def predecessor_work_packages + @predecessor_work_packages ||= predecessor_relations + .includes(:to) + .map(&:to) + end + + def children + @children ||= @work_package.children + end + + def all_relational_wp_ids + @work_package + .relations + .pluck(:from_id, :to_id) + .flatten + .uniq + end + end + end +end diff --git a/app/components/work_packages/date_picker/dialog_content_component.html.erb b/app/components/work_packages/date_picker/dialog_content_component.html.erb new file mode 100644 index 000000000000..1dbb88764764 --- /dev/null +++ b/app/components/work_packages/date_picker/dialog_content_component.html.erb @@ -0,0 +1,166 @@ +<%= + content_tag("turbo-frame", id: "wp-datepicker-dialog--content") do + component_wrapper(data: { "application-target": "dynamic", + controller: "work-packages--date-picker--preview" }) 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: "Overlay-body_autocomplete_height")) do + render(Primer::Alpha::UnderlinePanels.new(label: "Test navigation")) do |component| + component.with_tab(selected: true, id: "todo_1") do |tab| + tab.with_text { "Dates" } + tab.with_panel do + primer_form_with( + model: work_package, + url: work_package_datepicker_dialog_content_path, + method: :put, + id: DIALOG_FORM_ID, + data: { "work-packages--date-picker--preview-target": "form" }, + html: { autocomplete: "off" }, + ) do |f| + flex_layout do |body| + body.with_row(mb: 3) do + flex_layout(align_items: :flex_end, justify_content: :space_between) do |first_row| + first_row.with_column do + concat(render(Primer::Alpha::FormControl.new(label: I18n.t("work_packages.datepicker_modal.mode.title"))) do |component| + component.with_input do + render(Primer::Alpha::SegmentedControl.new("aria-label": I18n.t("work_packages.datepicker_modal.mode.title"))) do |control| + control.with_item(tag: :a, + href: work_package_datepicker_dialog_content_path(params.merge(schedule_manually: true).permit!), + data: { turbo_stream: true }, + label: I18n.t("work_packages.datepicker_modal.mode.manual"), + title: I18n.t("work_packages.datepicker_modal.mode.manual"), + selected: schedule_manually) + control.with_item(tag: :a, + href: work_package_datepicker_dialog_content_path(params.merge(schedule_manually: false).permit!), + data: { turbo_stream: true }, + label: I18n.t("work_packages.datepicker_modal.mode.automatic"), + title: I18n.t("work_packages.datepicker_modal.mode.automatic"), + selected: !schedule_manually) + end + end + end) + concat( + f.hidden_field(:work_package, + name: "work_package[schedule_manually]", + value: schedule_manually) + ) + concat( + f.hidden_field(:work_package, + name: "work_package[schedule_manually_touched]", + value: params[:schedule_manually].present?) + ) + concat( + f.hidden_field(:work_package, + name: "work_package[initial][schedule_manually]", + value: work_package.schedule_manually) + ) + end + + first_row.with_column(mb: 1) do + render(Primer::Alpha::CheckBox.new(name: "work_package[ignore_non_working_days]", + label: I18n.t("work_packages.datepicker_modal.ignore_non_working_days.title"), + checked: !work_package.ignore_non_working_days, + disabled: disabled?, + value: 0, + unchecked_value: 1, + data: { "work-packages--date-picker--preview-target": "fieldInput", + action: "work-packages--date-picker--preview#markFieldAsTouched" })) + end + end + end + + body.with_row(mb: 3) do + render(WorkPackages::DatePicker::Form.new(f, + work_package:, + focused_field:, + touched_field_map:, + disabled: disabled?)) + end + + body.with_row(mb: 3) do + helpers.angular_component_tag "opce-wp-modal-date-picker", + inputs: { + start_date: work_package.start_date, + due_date: work_package.due_date, + ignore_non_working_days: work_package.ignore_non_working_days, + schedule_manually:, + is_schedulable: !disabled?, + field_name: focused_field, + start_date_field_id: "work_package_start_date", + due_date_field_id: "work_package_due_date" + } + end + end + end + end + end + + component.with_tab(id: "todo_2") do |tab| + tab.with_text { "Predecessors" } + tab.with_counter(count: follow_relations.count) + tab.with_panel do + render(border_box_container(padding: :condensed)) do |component| + follow_relations.each do |relation| + component.with_row(scheme: :default) do + render(WorkPackageRelationsTab::RelationComponent.new(work_package:, + relation:, + editable: false)) + end + end + end + end + end + + component.with_tab(id: "todo_3") do |tab| + tab.with_text { "Successors" } + tab.with_counter(count: precedes_relations.count) + tab.with_panel do + render(border_box_container(padding: :condensed)) do |component| + precedes_relations.each do |relation| + component.with_row(scheme: :default) do + render(WorkPackageRelationsTab::RelationComponent.new(work_package:, + relation:, + editable: false)) + end + end + end + end + end + + component.with_tab(id: "todo_3") do |tab| + tab.with_text { "Children" } + tab.with_counter(count: children.count) + tab.with_panel do + render(border_box_container(padding: :condensed)) do |component| + children.each do |child| + component.with_row(scheme: :default) do + render(WorkPackageRelationsTab::RelationComponent.new(work_package:, + relation: nil, + child:, + editable: false)) + 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" })) do + I18n.t("button_cancel") + end + + footer.with_component(Primer::ButtonComponent.new(scheme: :primary, type: :submit, form: DIALOG_FORM_ID)) do + I18n.t("button_submit") + end + end + end + 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..fe83b97af4fc --- /dev/null +++ b/app/components/work_packages/date_picker/dialog_content_component.rb @@ -0,0 +1,72 @@ +#-- 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 + work_package.precedes_relations.visible + end + + def follow_relations + work_package.follows_relations.visible + end + + def children + work_package.children.visible + end + + def disabled? + !schedule_manually + end + + def show_banner? + true # TODO + end + end + end +end diff --git a/app/contracts/work_packages/base_contract.rb b/app/contracts/work_packages/base_contract.rb index 552d27bc9c2f..f0ced91279d2 100644 --- a/app/contracts/work_packages/base_contract.rb +++ b/app/contracts/work_packages/base_contract.rb @@ -480,7 +480,10 @@ 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 + if (!model.duration_before_type_cast.is_a?(String) && model.duration_before_type_cast != model.duration) || + (model.duration_before_type_cast.is_a?(String) && model.duration_before_type_cast.to_i.to_s != model.duration_before_type_cast) + errors.add :duration, :not_an_integer + end 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..99d31a4cecc8 --- /dev/null +++ b/app/controllers/work_packages/date_picker_controller.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +class WorkPackages::DatePickerController < ApplicationController + include OpTurbo::ComponentStream + + ERROR_PRONE_ATTRIBUTES = %i[start_date + due_date + duration].freeze + + layout false + + before_action :find_work_package + authorization_checked! :show, :update, :edit + + 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 edit + set_date_attributes_to_work_package + + render datepicker_modal_component + end + + def update + service_call = WorkPackages::UpdateService + .new(user: current_user, + model: @work_package) + .call(work_package_datepicker_params) + + if service_call.success? + respond_to do |format| + format.turbo_stream do + render turbo_stream: [] + end + end + else + respond_to do |format| + format.turbo_stream do + # Bundle 422 status code into stream response so + # Angular has context as to the success or failure of + # the request in order to fetch the new set of Work Package + # attributes in the ancestry solely on success. + render turbo_stream: [ + turbo_stream.morph("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 + params[:field] + 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 + if params[:schedule_manually].present? + params[:schedule_manually] + elsif params[:work_package].present? && params[:work_package][:schedule_manually].present? + params[:work_package][:schedule_manually] + else + work_package.schedule_manually + end + end + + def work_package_datepicker_params + if params[:work_package] + 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 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 +end diff --git a/app/controllers/work_packages_controller.rb b/app/controllers/work_packages_controller.rb index 81e3613a662e..c86e84778974 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/form.rb b/app/forms/work_packages/date_picker/form.rb new file mode 100644 index 000000000000..174763ee43f1 --- /dev/null +++ b/app/forms/work_packages/date_picker/form.rb @@ -0,0 +1,153 @@ +# -- 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 Form < 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:, + disabled:, + focused_field: :start_date, + touched_field_map: {}) + super() + + @work_package = work_package + @focused_field = focused_field_by_selection(focused_field) + @touched_field_map = touched_field_map + @disabled = disabled + end + + form do |query_form| + query_form.group(layout: :horizontal) do |group| + text_field(group, name: :start_date, label: I18n.t("attributes.start_date")) + text_field(group, name: :due_date, label: I18n.t("attributes.due_date")) + text_field(group, name: :duration, label: I18n.t("activerecord.attributes.work_package.duration")) + + hidden_touched_field(group, name: :start_date) + hidden_touched_field(group, name: :due_date) + hidden_touched_field(group, name: :duration) + hidden_touched_field(group, name: :ignore_non_working_days) + + group.fields_for(:initial) do |builder| + WorkPackages::DatePicker::InitialValuesForm.new(builder, work_package:) + end + end + end + + private + + def focused_field_by_selection(field) + field + end + + def text_field(group, + name:, + label:) + text_field_options = default_field_options(name).merge( + name:, + value: field_value(name), + disabled: @disabled, + label:, + validation_message: validation_message(name) + ) + + group.text_field(**text_field_options) + end + + def readonly_text_field(group, + name:, + label:, + placeholder: true) + text_field_options = default_field_options(name).merge( + name:, + value: field_value(name), + label:, + readonly: true, + classes: "input--readonly", + placeholder: ("-" if placeholder) + ) + + group.text_field(**text_field_options) + end + + def hidden_touched_field(group, name:) + group.hidden(name: :"#{name}_touched", + value: touched(name), + data: { "work-packages--date-picker--preview-target": "touchedFieldInput", + "referrer-field": name }) + end + + def touched(name) + @touched_field_map["#{name}_touched"] || false + 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" } + + 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..c4ff87b163ba --- /dev/null +++ b/app/forms/work_packages/date_picker/initial_values_form.rb @@ -0,0 +1,57 @@ +# -- 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:) + super() + + @work_package = work_package + end + + form do |form| + hidden_initial_field(form, name: :start_date) + hidden_initial_field(form, name: :due_date) + hidden_initial_field(form, name: :duration) + hidden_initial_field(form, name: :ignore_non_working_days) + 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..9863110334b9 --- /dev/null +++ b/app/views/work_packages/date_picker/show.html.erb @@ -0,0 +1 @@ +<%= render(WorkPackages::DatePicker::DialogContentComponent.new(work_package:, schedule_manually:))%> diff --git a/config/locales/en.yml b/config/locales/en.yml index 89a5745c5533..6d5fd89e914a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -684,6 +684,25 @@ en: no_results_title_text: There are currently no workflows. work_packages: + datepicker_modal: + banner: + description: + click_on_show_relations_to_open_gantt: 'Click on "%{button_name}" for Gantt overview.' + manual_gap_between_predecessors: "There is a gap between this and all predecessors." + manual_overlap_with_predecessors: "Overlaps with at least one predecessor." + manual_with_children: "This has child work package but their start dates are ignored." + title: + automatic_with_children: "The dates are determined by child work packages." + automatic_with_predecessor: "The start date is set by a predecessor." + manually_scheduled: "Manually scheduled. Dates not affected by relations." + ignore_non_working_days: + title: "Working days only" + mode: + title: "Scheduling mode" + automatic: "Automatic" + manual: "Manual" + show_relations: "Show relations" + x_descendants: one: "One descendant work package" other: "%{count} work package descendants" diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index 5e2600ad6473..92655ae3cd48 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -982,15 +982,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 2a9e1a53b7b1..851121195ad4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -619,6 +619,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 :progress, + only: :create, + controller: "work_packages/date_picker", + as: :work_package_progress + 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 51d140173014..3f6d1a88e802 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -237,6 +237,7 @@ import { appBaseSelector, ApplicationBaseComponent } from 'core-app/core/routing import { SpotSwitchComponent } from 'core-app/spot/components/switch/switch.component'; import { OPContextMenuService } from 'core-app/shared/components/op-context-menu/op-context-menu.service'; import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; +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 b39afd827d8f..9ec2880006e8 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,10 @@ export class PathHelperService { return `${this.workPackagePath(workPackageId)}/split_view/update_counter?counter=${counter}`; } + public workPackageDatepickerDialogContentPath(workPackageId:string|number):string { + 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/datepicker.module.ts b/frontend/src/app/shared/components/datepicker/datepicker.module.ts index 3cfc24d62ef0..5c422abc728d 100644 --- a/frontend/src/app/shared/components/datepicker/datepicker.module.ts +++ b/frontend/src/app/shared/components/datepicker/datepicker.module.ts @@ -8,15 +8,13 @@ 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 +25,7 @@ import { OpDatePickerSheetComponent } from 'core-app/shared/components/datepicke OpSpotModule, OpBasicDatePickerModule, OpenprojectModalModule, + OpenprojectContentLoaderModule, ], providers: [ @@ -34,22 +33,18 @@ 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/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/styles/datepicker.modal.sass b/frontend/src/app/shared/components/datepicker/styles/datepicker.modal.sass index 35d68546881f..63ca5463cda6 100644 --- a/frontend/src/app/shared/components/datepicker/styles/datepicker.modal.sass +++ b/frontend/src/app/shared/components/datepicker/styles/datepicker.modal.sass @@ -17,25 +17,11 @@ &_wide max-width: 600px - &--banner - margin-bottom: $spot-spacing-1 - - &:empty - visibility: hidden - - &--toggle-actions-container - display: grid - grid-template-columns: auto auto 1fr - grid-column-gap: $spot-spacing-2 - margin: 0 $spot-spacing-1 0 - .spot-form-field--input:has(spot-switch) - margin-bottom: 0px !important - &--dates-container display: grid grid-template-columns: 1fr 1fr 150px grid-gap: $spot-spacing-1 - margin: $spot-spacing-1 $spot-spacing-1 0 + margin: 0 $spot-spacing-1 &--date-field &_current, @@ -57,17 +43,6 @@ margin: 0.5rem auto 0 auto !important overflow: hidden - &--stretch-content - flex-grow: 1 - flex-shrink: 1 - overflow-y: auto - - // Unfortunately, we need an extra class - // Of specificity here to overwrite the - // nested spot-container styles - &.spot-container - margin-top: 0 - @media screen and (max-width: $breakpoint-sm) .op-datepicker-modal // Use same width as spot-modal mobile @@ -80,11 +55,6 @@ &--dates-container grid-template-columns: 1fr 1fr - &--toggle-actions-container - display: grid - grid-template-columns: 1fr - grid-row-gap: $spot-spacing-1 - &--date-form &:only-child grid-column: 1 / 3 diff --git a/frontend/src/app/shared/components/datepicker/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..b6a8a2f29442 --- /dev/null +++ b/frontend/src/app/shared/components/datepicker/wp-modal-date-picker/wp-modal-date-picker.component.ts @@ -0,0 +1,221 @@ +//-- 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 } from 'rxjs'; +import { filter } from 'rxjs/operators'; + +@Component({ + selector: 'op-wp-modal-date-picker', + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: [ + '../styles/datepicker.modal.sass', + ], +}) +export class OpWpModalDatePickerComponent extends UntilDestroyedMixin implements AfterViewInit { + @Input() public ignoreNonWorkingDays:boolean; + @Input() public scheduleManually:boolean; + + @Input() public startDate:Date; + @Input() public dueDate:Date; + + @Input() public isSchedulable:boolean = true; + @Input() public minimalSchedulingDate:Date|null; + + @Input() fieldName:'start_date'|'due_date' = 'start_date'; + @Input() startDateFieldId:string; + @Input() dueDateFieldId:string; + + @ViewChild('flatpickrTarget') flatpickrTarget:ElementRef; + + private datePickerInstance:DatePicker; + + constructor( + readonly injector:Injector, + readonly cdRef:ChangeDetectorRef, + readonly apiV3Service:ApiV3Service, + readonly I18n:I18nService, + readonly timezoneService:TimezoneService, + readonly deviceService:DeviceService, + readonly pathHelper:PathHelperService, + readonly elementRef:ElementRef, + ) { + super(); + populateInputsFromDataset(this); + } + + ngAfterViewInit():void { + this.initializeDatepicker(); + + document.addEventListener('date-picker:input-changed', this.changeListener.bind(this)); + } + + ngOnDestroy():void { + super.ngOnDestroy(); + + document.removeEventListener('date-picker:input-changed', this.changeListener.bind(this)); + } + + changeListener(event:CustomEvent) { + switch (event.detail.field) { + case 'work_package[start_date]': + this.startDate = event.detail.value; + break; + case 'work_package[due_date]': + this.dueDate = event.detail.value; + break; + case 'work_package[ignore_non_working_days]': + this.ignoreNonWorkingDays = event.detail.value !== 'true'; + break; + default: + // Case fallthrough for eslint + return; + } + + window.setTimeout(() => { + this.initializeDatepicker(); + }); + } + + private initializeDatepicker() { + this.datePickerInstance?.destroy(); + this.datePickerInstance = new DatePicker( + this.injector, + '#flatpickr-input', + [this.startDate || '', this.dueDate || ''], + { + mode: 'range', + showMonths: this.deviceService.isMobile ? 1 : 2, + inline: true, + onReady: (_date, _datestr, instance) => { + instance.calendarContainer.classList.add('op-datepicker-modal--flatpickr-instance'); + + this.ensureHoveredSelection(instance.calendarContainer); + }, + onChange: (dates:Date[], _datestr, instance) => { + // Todo: Make code better + if (this.fieldName === 'due_date') { + this.dueDate = dates[0]; + + if (this.dueDateFieldId) { + const dueDateField = document.getElementById(this.dueDateFieldId) as HTMLInputElement; + dueDateField.value = this.timezoneService.formattedISODate(this.dueDate); + dueDateField.dispatchEvent(new Event('input')); + } + + // Toggle the active field + if (this.startDateFieldId) { + document.getElementById(this.startDateFieldId)?.focus(); + } + this.fieldName = 'start_date'; + } else { + this.startDate = dates[0]; + + if (this.startDateFieldId) { + const dueDateField = document.getElementById(this.startDateFieldId) as HTMLInputElement; + dueDateField.value = this.timezoneService.formattedISODate(this.startDate); + dueDateField.dispatchEvent(new Event('input')); + } + + // Toggle the active field + if (this.dueDateFieldId) { + document.getElementById(this.dueDateFieldId)?.focus(); + } + this.fieldName = 'due_date'; + } + + instance.setDate([this.startDate, this.dueDate]); + }, + // eslint-disable-next-line @typescript-eslint/no-misused-promises + onDayCreate: async (dObj:Date[], dStr:string, fp:flatpickr.Instance, dayElem:DayElement) => { + onDayCreate( + dayElem, + this.ignoreNonWorkingDays, + await this.datePickerInstance?.isNonWorkingDay(dayElem.dateObj), + this.isDayDisabled(dayElem), + ); + }, + }, + this.flatpickrTarget.nativeElement, + ); + } + + private isDayDisabled(dayElement:DayElement):boolean { + const minimalDate = this.minimalSchedulingDate || null; + return !this.isSchedulable || (!this.scheduleManually && !!minimalDate && dayElement.dateObj <= minimalDate); + } + + /** + * When hovering selections in the range datepicker, the range usually + * stays active no matter where the cursor is. + * + * We want to hide any hovered selection preview when we leave the datepicker. + * @param calendarContainer + * @private + */ + private ensureHoveredSelection(calendarContainer:HTMLDivElement) { + fromEvent(calendarContainer, 'mouseenter') + .pipe( + this.untilDestroyed(), + ) + .subscribe(() => calendarContainer.classList.remove('flatpickr-container-suppress-hover')); + + fromEvent(calendarContainer, 'mouseleave') + .pipe( + this.untilDestroyed(), + filter(() => !(!!this.startDate && !!this.dueDate)), + ) + .subscribe(() => calendarContainer.classList.add('flatpickr-container-suppress-hover')); + } +} diff --git a/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-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 index dc89ece75d05..78876389694c 100644 --- a/frontend/src/app/shared/components/datepicker/wp-single-date-form/wp-single-date-form.component.html +++ b/frontend/src/app/shared/components/datepicker/wp-single-date-form/wp-single-date-form.component.html @@ -7,18 +7,20 @@ (submit)="doSave($event)" tabindex="0" > - + class="op-datepicker-modal--banner" + >-->
+ - + + + + + + + + + + + + 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..1060a96c101a 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/combined-date-edit-field.component.ts @@ -26,14 +26,32 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { Component } from '@angular/core'; +import { + ChangeDetectorRef, + Component, + ElementRef, + Inject, + Injector, + OnInit, +} from '@angular/core'; import { DatePickerEditFieldComponent } from 'core-app/shared/components/fields/edit/field-types/date-picker-edit-field.component'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset'; +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { IFieldSchema } from 'core-app/shared/components/fields/field.base'; +import { EditFieldHandler } from 'core-app/shared/components/fields/edit/editing-portal/edit-field-handler'; +import { + OpEditingPortalChangesetToken, + OpEditingPortalHandlerToken, + OpEditingPortalSchemaToken, +} from 'core-app/shared/components/fields/edit/edit-field.component'; @Component({ templateUrl: './combined-date-edit-field.component.html', }) -export class CombinedDateEditFieldComponent extends DatePickerEditFieldComponent { +export class CombinedDateEditFieldComponent extends DatePickerEditFieldComponent implements OnInit { dates = ''; opened = false; @@ -46,6 +64,10 @@ export class CombinedDateEditFieldComponent extends DatePickerEditFieldComponent }, }; + ngOnInit() { + super.ngOnInit(); + } + get isMultiDate():boolean { return !this.change.schema.isMilestone; } @@ -69,12 +91,10 @@ export class CombinedDateEditFieldComponent extends DatePickerEditFieldComponent public save():void { this.handler.handleUserSubmit(); - this.onModalClosed(); } public cancel():void { this.handler.reset(); - this.onModalClosed(); } // Overwrite super in order to set the initial dates. diff --git a/frontend/src/app/shared/components/fields/edit/field-types/date-picker-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/date-picker-edit-field.component.ts index a08dfa29ab44..0b2a2e833abd 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/date-picker-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/date-picker-edit-field.component.ts @@ -31,11 +31,25 @@ import { OnDestroy, OnInit, Injector, + ElementRef, + Inject, + ChangeDetectorRef, } from '@angular/core'; import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; import { TimezoneService } from 'core-app/core/datetime/timezone.service'; -import { EditFieldComponent } from 'core-app/shared/components/fields/edit/edit-field.component'; +import { + EditFieldComponent, + OpEditingPortalChangesetToken, + OpEditingPortalHandlerToken, + OpEditingPortalSchemaToken, +} from 'core-app/shared/components/fields/edit/edit-field.component'; import { DeviceService } from 'core-app/core/browser/device.service'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset'; +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { IFieldSchema } from 'core-app/shared/components/fields/field.base'; +import { EditFieldHandler } from 'core-app/shared/components/fields/edit/editing-portal/edit-field-handler'; @Directive() export abstract class DatePickerEditFieldComponent extends EditFieldComponent implements OnInit, OnDestroy { @@ -43,10 +57,24 @@ export abstract class DatePickerEditFieldComponent extends EditFieldComponent im @InjectField() deviceService:DeviceService; - @InjectField() injector:Injector; + turboFrameSrc:string; + + constructor( + readonly I18n:I18nService, + readonly elementRef:ElementRef, + @Inject(OpEditingPortalChangesetToken) protected change:ResourceChangeset, + @Inject(OpEditingPortalSchemaToken) public schema:IFieldSchema, + @Inject(OpEditingPortalHandlerToken) readonly handler:EditFieldHandler, + readonly cdRef:ChangeDetectorRef, + readonly injector:Injector, + readonly pathHelper:PathHelperService, + ) { + super(I18n, elementRef, change, schema, handler, cdRef, injector); + } ngOnInit():void { super.ngOnInit(); + this.turboFrameSrc = `${this.pathHelper.workPackageDatepickerDialogContentPath(this.change.id)}?field=${this.name}`; this.handler .$onUserActivate @@ -63,4 +91,25 @@ export abstract class DatePickerEditFieldComponent extends EditFieldComponent im } public showDatePickerModal():void { } + + public handleSuccessfulCreate(JSONResponse:{ duration:number, startDate:Date, dueDate:Date, includeNonWorkingDays:boolean, scheduleManually:boolean }):void { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.duration = JSONResponse.duration; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.dueDate = JSONResponse.dueDate; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.startDate = JSONResponse.startDate; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.includeNonWorkingDays = JSONResponse.includeNonWorkingDays; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.scheduleManually = JSONResponse.scheduleManually; + + this.onModalClosed(); + } + + public handleSuccessfulUpdate():void { + this.onModalClosed(); + } + + public onModalClosed():void { } } diff --git a/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.html b/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.html index 1e64d5f96dfb..316ecf8f304e 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.html +++ b/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.html @@ -13,12 +13,25 @@ disabled="disabled" [id]="handler.htmlId" /> - - + + + + + + + + + + + + + diff --git a/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.ts index 6545320ea559..d3ece8129151 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/days-duration-edit-field.component.ts @@ -28,16 +28,32 @@ import { ChangeDetectionStrategy, + ChangeDetectorRef, Component, + ElementRef, + Inject, + Injector, + OnInit, } from '@angular/core'; import { DatePickerEditFieldComponent } from 'core-app/shared/components/fields/edit/field-types/date-picker-edit-field.component'; import * as moment from 'moment-timezone'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { + OpEditingPortalChangesetToken, + OpEditingPortalHandlerToken, + OpEditingPortalSchemaToken, +} from 'core-app/shared/components/fields/edit/edit-field.component'; +import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset'; +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { IFieldSchema } from 'core-app/shared/components/fields/field.base'; +import { EditFieldHandler } from 'core-app/shared/components/fields/edit/editing-portal/edit-field-handler'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; @Component({ templateUrl: './days-duration-edit-field.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class DaysDurationEditFieldComponent extends DatePickerEditFieldComponent { +export class DaysDurationEditFieldComponent extends DatePickerEditFieldComponent implements OnInit{ opened = false; public get formattedValue():number { @@ -56,7 +72,7 @@ export class DaysDurationEditFieldComponent extends DatePickerEditFieldComponent this.opened = true; } - save() { + onModalClosed() { this.handler.handleUserSubmit(); this.opened = false; } diff --git a/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.html b/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.html index 73a7cba63181..d3e433c89e15 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.html +++ b/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.html @@ -37,9 +37,15 @@
+ [attr.src]="this.frameSrc" + opModalWithTurboContent + [change]="change" + [resource]="resource" + (successfulCreate)="handleSuccessfulCreate($event)" + (successfulUpdate)="handleSuccessfulUpdate()" + (cancel)="cancel()" + > diff --git a/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.ts index 8abf179696ee..8660af62d91b 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.ts @@ -29,22 +29,17 @@ */ import { - AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Inject, Injector, - OnDestroy, OnInit, - ViewChild, } from '@angular/core'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; -import { - ProgressEditFieldComponent, -} from 'core-app/shared/components/fields/edit/field-types/progress-edit-field.component'; +import { ProgressEditFieldComponent } from 'core-app/shared/components/fields/edit/field-types/progress-edit-field.component'; import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { IFieldSchema } from 'core-app/shared/components/fields/field.base'; @@ -55,7 +50,6 @@ import { OpEditingPortalSchemaToken, } from 'core-app/shared/components/fields/edit/edit-field.component'; import { HalEventsService } from 'core-app/features/hal/services/hal-events.service'; -import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { ToastService } from 'core-app/shared/components/toaster/toast.service'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; import { TimezoneService } from 'core-app/core/datetime/timezone.service'; @@ -65,9 +59,7 @@ import { TimezoneService } from 'core-app/core/datetime/timezone.service'; styleUrls: ['./progress-popover-edit-field.component.sass'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ProgressPopoverEditFieldComponent extends ProgressEditFieldComponent implements OnInit, AfterViewInit, OnDestroy { - @ViewChild('frameElement') frameElement:ElementRef; - +export class ProgressPopoverEditFieldComponent extends ProgressEditFieldComponent implements OnInit { text = { title: this.I18n.t('js.work_packages.progress.title'), button_close: this.I18n.t('js.button_close'), @@ -107,22 +99,6 @@ export class ProgressPopoverEditFieldComponent extends ProgressEditFieldComponen this.frameId = 'work_package_progress_modal'; } - ngAfterViewInit() { - this - .frameElement - .nativeElement - .addEventListener('turbo:submit-end', this.contextBasedListener.bind(this)); - } - - ngOnDestroy() { - super.ngOnDestroy(); - - this - .frameElement - .nativeElement - .removeEventListener('turbo:submit-end', this.contextBasedListener.bind(this)); - } - public get asHoursOrPercent():string { return this.name === 'percentageDone' ? this.asPercent : this.asHours; } @@ -156,62 +132,19 @@ export class ProgressPopoverEditFieldComponent extends ProgressEditFieldComponen return value; } - private contextBasedListener(event:CustomEvent) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (this.resource.id === 'new') { - void this.propagateSuccessfulCreate(event); - } else { - this.propagateSuccessfulUpdate(event); - } - } - - private async propagateSuccessfulCreate(event:CustomEvent) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const { fetchResponse } = event.detail; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (fetchResponse.succeeded) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access - const JSONresponse = await this.extractJSONFromResponse(fetchResponse.response.body); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment - this.resource.estimatedTime = JSONresponse.estimatedTime; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment - this.resource.remainingTime = JSONresponse.remainingTime; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment - this.resource.percentageDone = JSONresponse.percentageDone; + public handleSuccessfulCreate(JSONResponse:{ estimatedTime:string, remainingTime:string, percentageDone:string }):void { +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.estimatedTime = JSONResponse.estimatedTime; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.remainingTime = JSONResponse.remainingTime; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + this.resource.percentageDone = JSONResponse.percentageDone; - this.onModalClosed(); - - this.change.push(); - this.cdRef.detectChanges(); - } - } - - private propagateSuccessfulUpdate(event:CustomEvent) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const { fetchResponse } = event.detail; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (fetchResponse.succeeded) { - this.halEvents.push( - this.resource as WorkPackageResource, - { eventType: 'updated' }, - ); - - void this.apiV3Service.work_packages.id(this.resource as WorkPackageResource).refresh(); - - this.onModalClosed(); - - this.toastService.addSuccess(this.I18n.t('js.notice_successful_update')); - } + this.onModalClosed(); } - private async extractJSONFromResponse(response:ReadableStream) { - const readStream = await response.getReader().read(); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return JSON.parse(new TextDecoder('utf-8').decode(new Uint8Array(readStream.value as ArrayBufferLike))); + public handleSuccessfulUpdate():void { + this.onModalClosed(); } private updateFrameSrc():void { diff --git a/frontend/src/app/shared/components/fields/edit/modal-with-turbo-content/modal-with-turbo-content.directive.ts b/frontend/src/app/shared/components/fields/edit/modal-with-turbo-content/modal-with-turbo-content.directive.ts new file mode 100644 index 000000000000..da2bc2bbb2d6 --- /dev/null +++ b/frontend/src/app/shared/components/fields/edit/modal-with-turbo-content/modal-with-turbo-content.directive.ts @@ -0,0 +1,143 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { + AfterViewInit, + ChangeDetectorRef, + Directive, + ElementRef, + EventEmitter, + Input, + OnDestroy, + Output, +} from '@angular/core'; +import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { HalEventsService } from 'core-app/features/hal/services/hal-events.service'; +import { ToastService } from 'core-app/shared/components/toaster/toast.service'; +import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset'; + +@Directive({ + selector: '[opModalWithTurboContent]', +}) +export class ModalWithTurboContentDirective implements AfterViewInit, OnDestroy { + @Input() resource:HalResource; + @Input() change:ResourceChangeset; + + @Output() successfulCreate= new EventEmitter(); + @Output() successfulUpdate= new EventEmitter(); + @Output() cancel= new EventEmitter(); + + constructor( + readonly elementRef:ElementRef, + readonly cdRef:ChangeDetectorRef, + readonly halEvents:HalEventsService, + readonly apiV3Service:ApiV3Service, + readonly toastService:ToastService, + readonly I18n:I18nService, + ) { + + } + + ngAfterViewInit() { + this + .elementRef + .nativeElement + .addEventListener('turbo:submit-end', this.contextBasedListener.bind(this)); + + document + .addEventListener('cancelModalWithTurboContent', this.cancelListener.bind(this)); + } + + ngOnDestroy() { + this + .elementRef + .nativeElement + .removeEventListener('turbo:submit-end', this.contextBasedListener.bind(this)); + + document + .removeEventListener('cancelModalWithTurboContent', this.cancelListener.bind(this)); + } + + private contextBasedListener(event:CustomEvent) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (this.resource.id === 'new') { + void this.propagateSuccessfulCreate(event); + } else { + this.propagateSuccessfulUpdate(event); + } + } + + private cancelListener():void { + this.cancel.emit(); + } + + private async propagateSuccessfulCreate(event:CustomEvent) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { fetchResponse } = event.detail; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (fetchResponse.succeeded) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access + const JSONresponse:unknown = await this.extractJSONFromResponse(fetchResponse.response.body); + + this.successfulCreate.emit(JSONresponse); + + this.change.push(); + this.cdRef.detectChanges(); + } + } + + private propagateSuccessfulUpdate(event:CustomEvent) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { fetchResponse } = event.detail; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (fetchResponse.succeeded) { + this.halEvents.push( + this.resource as WorkPackageResource, + { eventType: 'updated' }, + ); + + void this.apiV3Service.work_packages.id(this.resource as WorkPackageResource).refresh(); + + this.successfulUpdate.emit(); + + this.toastService.addSuccess(this.I18n.t('js.notice_successful_update')); + } + } + + private async extractJSONFromResponse(response:ReadableStream) { + const readStream = await response.getReader().read(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return JSON.parse(new TextDecoder('utf-8').decode(new Uint8Array(readStream.value as ArrayBufferLike))); + } +} diff --git a/frontend/src/app/shared/components/fields/openproject-fields.module.ts b/frontend/src/app/shared/components/fields/openproject-fields.module.ts index 39f0b5f71e30..dfff0f97a659 100644 --- a/frontend/src/app/shared/components/fields/openproject-fields.module.ts +++ b/frontend/src/app/shared/components/fields/openproject-fields.module.ts @@ -107,6 +107,7 @@ import { import { CombinedDateEditFieldComponent } from './edit/field-types/combined-date-edit-field.component'; import { NgSelectModule } from '@ng-select/ng-select'; import { FormsModule } from '@angular/forms'; +import { ModalWithTurboContentDirective } from 'core-app/shared/components/fields/edit/modal-with-turbo-content/modal-with-turbo-content.directive'; @NgModule({ imports: [ @@ -171,6 +172,8 @@ import { FormsModule } from '@angular/forms'; AttributeLabelMacroComponent, WorkPackageQuickinfoMacroComponent, + + ModalWithTurboContentDirective, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], }) diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 00be06fa2270..2fd5edc8b056 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -67,12 +67,6 @@ import { CopyToClipboardService } from './components/copy-to-clipboard/copy-to-c import { CopyToClipboardComponent } from './components/copy-to-clipboard/copy-to-clipboard.component'; import { OpDateTimeComponent } from './components/date/op-date-time.component'; import { ToastComponent } from './components/toaster/toast.component'; - -// Old datepickers -import { - OpMultiDatePickerComponent, -} from 'core-app/shared/components/datepicker/multi-date-picker/multi-date-picker.component'; - import { ToastsContainerComponent } from './components/toaster/toasts-container.component'; import { UploadProgressComponent } from './components/toaster/upload-progress.component'; import { ResizerComponent } from './components/resizer/resizer.component'; @@ -197,9 +191,6 @@ export function bootstrapModule(injector:Injector):void { OpProjectIncludeListComponent, OpLoadingProjectListComponent, - // Old datepickers - OpMultiDatePickerComponent, - OpNonWorkingDaysListComponent, ], providers: [ @@ -251,9 +242,6 @@ export function bootstrapModule(injector:Injector):void { OpNonWorkingDaysListComponent, - // Old datepickers - OpMultiDatePickerComponent, - ShareUpsaleComponent, ], }) diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index 2ef53fc4dd50..8c58cd2161fd 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -44,6 +44,7 @@ page-header, sub-header, .op-work-package-details-tab-component, +.UnderlineNav, .tabnav, .Box-header, action-menu anchored-position diff --git a/frontend/src/stimulus/controllers/dynamic/work-packages/date-picker/preview.controller.ts b/frontend/src/stimulus/controllers/dynamic/work-packages/date-picker/preview.controller.ts new file mode 100644 index 000000000000..85091887cb9c --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/work-packages/date-picker/preview.controller.ts @@ -0,0 +1,73 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ + +import { DialogPreviewController } from '../dialog/preview.controller'; + +export default class PreviewController extends DialogPreviewController { + markFieldAsTouched(event:{ target:HTMLInputElement }) { + super.markFieldAsTouched(event); + } + + // Ensures that on create forms, there is an "id" for the un-persisted + // work package when sending requests to the edit action for previews. + ensureValidPathname(formAction:string):string { + const wpPath = new URL(formAction); + + if (wpPath.pathname.endsWith('/work_packages/date_picker')) { + // Replace /work_packages/date_picker with /work_packages/new/date_picker + wpPath.pathname = wpPath.pathname.replace('/work_packages/date_picker', '/work_packages/new/date_picker'); + } + + return wpPath.toString(); + } + + ensureValidWpAction(wpPath:string):string { + return wpPath.endsWith('/work_packages/new/date_picker') ? 'new' : 'edit'; + } + + dispatchChangeEvent(field:HTMLInputElement) { + document.dispatchEvent( + new CustomEvent('date-picker:input-changed', { + detail: { + field: field.name, + value: this.getValueFor(field), + }, + }), + ); + } + + private getValueFor(field:HTMLInputElement):string { + if (field.type === 'checkbox') { + return field.checked.toString(); + } + + return field.value; + } +} diff --git a/frontend/src/stimulus/controllers/dynamic/work-packages/dialog/preview.controller.ts b/frontend/src/stimulus/controllers/dynamic/work-packages/dialog/preview.controller.ts new file mode 100644 index 000000000000..f51d4964e7c8 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/work-packages/dialog/preview.controller.ts @@ -0,0 +1,243 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ + +import { Controller } from '@hotwired/stimulus'; +import { debounce, DebouncedFunc } from 'lodash'; +import Idiomorph from 'idiomorph/dist/idiomorph.cjs'; + +interface TurboBeforeFrameRenderEventDetail { + render:(currentElement:HTMLElement, newElement:HTMLElement) => void; +} + +interface HTMLTurboFrameElement extends HTMLElement { + src:string; +} + +export abstract class DialogPreviewController extends Controller { + static targets = [ + 'form', + 'fieldInput', + 'initialValueInput', + 'touchedFieldInput', + ]; + + declare readonly fieldInputTargets:HTMLInputElement[]; + declare readonly formTarget:HTMLFormElement; + declare readonly initialValueInputTargets:HTMLInputElement[]; + declare readonly touchedFieldInputTargets:HTMLInputElement[]; + + private debouncedPreview:DebouncedFunc<(event:Event) => void>; + private frameMorphRenderer:(event:CustomEvent) => void; + private targetFieldName:string; + private touchedFields:Set; + + connect() { + this.touchedFields = new Set(); + this.touchedFieldInputTargets.forEach((input) => { + const fieldName = input.dataset.referrerField; + if (fieldName && input.value === 'true') { + this.touchedFields.add(fieldName); + } + }); + + this.debouncedPreview = debounce((event:Event) => { void this.preview(event); }, 200); + + // Turbo supports morphing, by adding the + // attribute. However, it does not work that well with primer input: when + // adding "data-turbo-permanent" to keep value and focus on the active + // element, it also keeps the `aria-describedby` attribute which references + // caption and validation element ids. As these elements are morphed and get + // new ids, the ids referenced by `aria-describedby` are stale. This makes + // caption and validation message unaccessible for screen readers and other + // assistive technologies. This is why morph cannot be used here. + this.frameMorphRenderer = (event:CustomEvent) => { + event.detail.render = (currentElement:HTMLElement, newElement:HTMLElement) => { + Idiomorph.morph(currentElement, newElement, { + ignoreActiveValue: true, + callbacks: { + beforeNodeMorphed: (oldNode:Element, newNode:Element) => { + // In case the element is an OpenProject custom dom element, morphing is prevented. + return !oldNode.tagName?.startsWith('OPCE-'); + }, + afterNodeMorphed: (oldNode:Element, newNode:Element) => { + if (newNode.tagName === "INPUT" && (newNode as HTMLInputElement).name && (newNode as HTMLInputElement).name.startsWith('work_package[')) { + this.dispatchChangeEvent((newNode as HTMLInputElement)); + } + }, + }, + }); + }; + }; + + this.fieldInputTargets.forEach((target) => { + if (target.tagName.toLowerCase() === 'select') { + target.addEventListener('change', this.debouncedPreview); + } else { + target.addEventListener('input', this.debouncedPreview); + } + + if (target.dataset.focus === 'true') { + this.focusAndSetCursorPositionToEndOfInput(target); + } + }); + + const turboFrame = this.formTarget.closest('turbo-frame') as HTMLTurboFrameElement; + turboFrame.addEventListener('turbo:before-frame-render', this.frameMorphRenderer); + } + + disconnect() { + this.debouncedPreview.cancel(); + this.fieldInputTargets.forEach((target) => { + if (target.tagName.toLowerCase() === 'select') { + target.removeEventListener('change', this.debouncedPreview); + } else { + target.removeEventListener('input', this.debouncedPreview); + } + }); + const turboFrame = this.formTarget.closest('turbo-frame') as HTMLTurboFrameElement; + if (turboFrame) { + turboFrame.removeEventListener('turbo:before-frame-render', this.frameMorphRenderer); + } + } + + protected cancel():void { + document.dispatchEvent(new CustomEvent('cancelModalWithTurboContent')); + } + + markFieldAsTouched(event:{ target:HTMLInputElement }) { + this.targetFieldName = event.target.name.replace(/^work_package\[([^\]]+)\]$/, '$1'); + this.markTouched(this.targetFieldName); + } + + async preview(event:Event) { + let field:HTMLInputElement; + if (event.type === 'blur') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + field = (event as FocusEvent).relatedTarget as HTMLInputElement; + } else { + field = event.target as HTMLInputElement; + } + + const form = this.formTarget; + const formData = new FormData(form) as unknown as undefined; + const formParams = new URLSearchParams(formData); + + const wpParams = Array.from(formParams.entries()) + .filter(([key, _]) => key.startsWith('work_package')); + wpParams.push(['field', field?.name ?? '']); + + const wpPath = this.ensureValidPathname(form.action); + const wpAction = this.ensureValidWpAction(wpPath); + + const editUrl = `${wpPath}/${wpAction}?${new URLSearchParams(wpParams).toString()}`; + const turboFrame = this.formTarget.closest('turbo-frame') as HTMLTurboFrameElement; + + if (turboFrame) { + turboFrame.src = editUrl; + } + } + + private focusAndSetCursorPositionToEndOfInput(field:HTMLInputElement) { + field.focus(); + field.setSelectionRange( + field.value.length, + field.value.length, + ); + } + + abstract ensureValidPathname(formAction:string):string; + + abstract ensureValidWpAction(path:string):string; + + abstract dispatchChangeEvent(field:HTMLInputElement|null):void; + + protected isBeingEdited(fieldName:string) { + return fieldName === this.targetFieldName; + } + + // Finds the hidden initial value input based on a field name. + // + // The initial value input field holds the initial value of the work package + // before being set by the user or derived. + private findInitialValueInput(fieldName:string):HTMLInputElement|undefined { + return this.initialValueInputTargets.find((input) => + (input.dataset.referrerField === fieldName)); + } + + // Finds the value field input based on a field name. + // + // The value field input holds the current value of a field. + protected findValueInput(fieldName:string):HTMLInputElement|undefined { + return this.fieldInputTargets.find((input) => + (input.name === fieldName) || (input.name === `work_package[${fieldName}]`)); + } + + protected isTouchedAndEmpty(fieldName:string):boolean { + return this.isTouched(fieldName) && this.isValueEmpty(fieldName); + } + + protected isTouched(fieldName:string):boolean { + return this.touchedFields.has(fieldName); + } + + protected isInitialValueEmpty(fieldName:string):boolean { + const valueInput = this.findInitialValueInput(fieldName); + return valueInput?.value === ''; + } + + protected isValueEmpty(fieldName:string):boolean { + const valueInput = this.findValueInput(fieldName); + return valueInput?.value === ''; + } + + protected isValueSet(fieldName:string):boolean { + const valueInput = this.findValueInput(fieldName); + return valueInput !== undefined && valueInput.value !== ''; + } + + protected markTouched(fieldName:string):void { + this.touchedFields.add(fieldName); + this.updateTouchedFieldHiddenInputs(); + } + + protected markUntouched(fieldName:string):void { + this.touchedFields.delete(fieldName); + this.updateTouchedFieldHiddenInputs(); + } + + private updateTouchedFieldHiddenInputs():void { + this.touchedFieldInputTargets.forEach((input) => { + const fieldName = input.dataset.referrerField; + if (fieldName) { + input.value = this.isTouched(fieldName) ? 'true' : 'false'; + } + }); + } +} diff --git a/frontend/src/stimulus/controllers/dynamic/work-packages/progress/preview.controller.ts b/frontend/src/stimulus/controllers/dynamic/work-packages/progress/preview.controller.ts index 61b253b88b7d..32c4e625e25e 100644 --- a/frontend/src/stimulus/controllers/dynamic/work-packages/progress/preview.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/work-packages/progress/preview.controller.ts @@ -28,142 +28,20 @@ * ++ */ -import { Controller } from '@hotwired/stimulus'; -import { debounce, DebouncedFunc } from 'lodash'; -import Idiomorph from 'idiomorph/dist/idiomorph.cjs'; - -interface TurboBeforeFrameRenderEventDetail { - render:(currentElement:HTMLElement, newElement:HTMLElement) => void; -} - -interface HTMLTurboFrameElement extends HTMLElement { - src:string; -} - -export default class PreviewController extends Controller { - static targets = [ - 'form', - 'progressInput', - 'initialValueInput', - 'touchedFieldInput', - ]; - - declare readonly progressInputTargets:HTMLInputElement[]; - declare readonly formTarget:HTMLFormElement; - declare readonly initialValueInputTargets:HTMLInputElement[]; - declare readonly touchedFieldInputTargets:HTMLInputElement[]; - - private debouncedPreview:DebouncedFunc<(event:Event) => void>; - private frameMorphRenderer:(event:CustomEvent) => void; - private targetFieldName:string; - private touchedFields:Set; - - connect() { - this.touchedFields = new Set(); - this.touchedFieldInputTargets.forEach((input) => { - const fieldName = input.dataset.referrerField; - if (fieldName && input.value === 'true') { - this.touchedFields.add(fieldName); - } - }); - - this.debouncedPreview = debounce((event:Event) => { void this.preview(event); }, 100); - - // Turbo supports morphing, by adding the - // attribute. However, it does not work that well with primer input: when - // adding "data-turbo-permanent" to keep value and focus on the active - // element, it also keeps the `aria-describedby` attribute which references - // caption and validation element ids. As these elements are morphed and get - // new ids, the ids referenced by `aria-describedby` are stale. This makes - // caption and validation message unaccessible for screen readers and other - // assistive technologies. This is why morph cannot be used here. - this.frameMorphRenderer = (event:CustomEvent) => { - event.detail.render = (currentElement:HTMLElement, newElement:HTMLElement) => { - Idiomorph.morph(currentElement, newElement, { ignoreActiveValue: true }); - }; - }; - - this.progressInputTargets.forEach((target) => { - if (target.tagName.toLowerCase() === 'select') { - target.addEventListener('change', this.debouncedPreview); - } else { - target.addEventListener('input', this.debouncedPreview); - } - target.addEventListener('blur', this.debouncedPreview); - - if (target.dataset.focus === 'true') { - this.focusAndSetCursorPositionToEndOfInput(target); - } - }); - - const turboFrame = this.formTarget.closest('turbo-frame') as HTMLTurboFrameElement; - turboFrame.addEventListener('turbo:before-frame-render', this.frameMorphRenderer); - } - - disconnect() { - this.debouncedPreview.cancel(); - this.progressInputTargets.forEach((target) => { - if (target.tagName.toLowerCase() === 'select') { - target.removeEventListener('change', this.debouncedPreview); - } else { - target.removeEventListener('input', this.debouncedPreview); - } - target.removeEventListener('blur', this.debouncedPreview); - }); - const turboFrame = this.formTarget.closest('turbo-frame') as HTMLTurboFrameElement; - if (turboFrame) { - turboFrame.removeEventListener('turbo:before-frame-render', this.frameMorphRenderer); - } - } +import { DialogPreviewController } from '../dialog/preview.controller'; +export default class PreviewController extends DialogPreviewController { markFieldAsTouched(event:{ target:HTMLInputElement }) { - this.targetFieldName = event.target.name.replace(/^work_package\[([^\]]+)\]$/, '$1'); - this.markTouched(this.targetFieldName); + super.markFieldAsTouched(event); if (this.isWorkBasedMode()) { this.keepWorkValue(); } } - async preview(event:Event) { - let field:HTMLInputElement; - if (event.type === 'blur') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - field = (event as FocusEvent).relatedTarget as HTMLInputElement; - } else { - field = event.target as HTMLInputElement; - } - - const form = this.formTarget; - const formData = new FormData(form) as unknown as undefined; - const formParams = new URLSearchParams(formData); - - const wpParams = Array.from(formParams.entries()) - .filter(([key, _]) => key.startsWith('work_package')); - wpParams.push(['field', field?.name ?? '']); - - const wpPath = this.ensureValidPathname(form.action); - const wpAction = wpPath.endsWith('/work_packages/new/progress') ? 'new' : 'edit'; - - const editUrl = `${wpPath}/${wpAction}?${new URLSearchParams(wpParams).toString()}`; - const turboFrame = this.formTarget.closest('turbo-frame') as HTMLTurboFrameElement; - - if (turboFrame) { - turboFrame.src = editUrl; - } - } - - private focusAndSetCursorPositionToEndOfInput(field:HTMLInputElement) { - field.focus(); - field.setSelectionRange( - field.value.length, - field.value.length, - ); - } - // Ensures that on create forms, there is an "id" for the un-persisted // work package when sending requests to the edit action for previews. - private ensureValidPathname(formAction:string):string { + ensureValidPathname(formAction:string):string { const wpPath = new URL(formAction); if (wpPath.pathname.endsWith('/work_packages/progress')) { @@ -174,126 +52,70 @@ export default class PreviewController extends Controller { return wpPath.toString(); } + ensureValidWpAction(wpPath:string):string { + return wpPath.endsWith('/work_packages/new/progress') ? 'new' : 'edit'; + } + + // Inheritance compliance + dispatchChangeEvent() {} + private isWorkBasedMode() { - return this.findValueInput('done_ratio') !== undefined; + return super.findValueInput('done_ratio') !== undefined; } private keepWorkValue() { - if (this.isInitialValueEmpty('estimated_hours') && !this.isTouched('estimated_hours')) { + if (super.isInitialValueEmpty('estimated_hours') && !super.isTouched('estimated_hours')) { // let work be derived return; } - if (this.isBeingEdited('estimated_hours')) { + if (super.isBeingEdited('estimated_hours')) { this.untouchFieldsWhenWorkIsEdited(); - } else if (this.isBeingEdited('remaining_hours')) { + } else if (super.isBeingEdited('remaining_hours')) { this.untouchFieldsWhenRemainingWorkIsEdited(); - } else if (this.isBeingEdited('done_ratio')) { + } else if (super.isBeingEdited('done_ratio')) { this.untouchFieldsWhenPercentCompleteIsEdited(); } } private untouchFieldsWhenWorkIsEdited() { if (this.areBothTouched('remaining_hours', 'done_ratio')) { - if (this.isValueEmpty('done_ratio') && this.isValueEmpty('remaining_hours')) { + if (super.isValueEmpty('done_ratio') && super.isValueEmpty('remaining_hours')) { return; } - if (this.isValueEmpty('done_ratio')) { - this.markUntouched('done_ratio'); + if (super.isValueEmpty('done_ratio')) { + super.markUntouched('done_ratio'); } else { - this.markUntouched('remaining_hours'); + super.markUntouched('remaining_hours'); } - } else if (this.isTouchedAndEmpty('remaining_hours') && this.isValueSet('done_ratio')) { + } else if (super.isTouchedAndEmpty('remaining_hours') && super.isValueSet('done_ratio')) { // force remaining work derivation - this.markUntouched('remaining_hours'); - this.markTouched('done_ratio'); - } else if (this.isTouchedAndEmpty('done_ratio') && this.isValueSet('remaining_hours')) { + super.markUntouched('remaining_hours'); + super.markTouched('done_ratio'); + } else if (super.isTouchedAndEmpty('done_ratio') && super.isValueSet('remaining_hours')) { // force % complete derivation - this.markUntouched('done_ratio'); - this.markTouched('remaining_hours'); + super.markUntouched('done_ratio'); + super.markTouched('remaining_hours'); } } - private untouchFieldsWhenRemainingWorkIsEdited() { - if (this.isTouchedAndEmpty('estimated_hours') && this.isValueSet('done_ratio')) { + private untouchFieldsWhenRemainingWorkIsEdited():void { + if (super.isTouchedAndEmpty('estimated_hours') && super.isValueSet('done_ratio')) { // force work derivation - this.markUntouched('estimated_hours'); - this.markTouched('done_ratio'); - } else if (this.isValueSet('estimated_hours')) { - this.markUntouched('done_ratio'); + super.markUntouched('estimated_hours'); + super.markTouched('done_ratio'); + } else if (super.isValueSet('estimated_hours')) { + super.markUntouched('done_ratio'); } } - private untouchFieldsWhenPercentCompleteIsEdited() { - if (this.isValueSet('estimated_hours')) { - this.markUntouched('remaining_hours'); + private untouchFieldsWhenPercentCompleteIsEdited():void { + if (super.isValueSet('estimated_hours')) { + super.markUntouched('remaining_hours'); } } - private areBothTouched(fieldName1:string, fieldName2:string) { - return this.isTouched(fieldName1) && this.isTouched(fieldName2); - } - - private isBeingEdited(fieldName:string) { - return fieldName === this.targetFieldName; - } - - // Finds the hidden initial value input based on a field name. - // - // The initial value input field holds the initial value of the work package - // before being set by the user or derived. - private findInitialValueInput(fieldName:string):HTMLInputElement|undefined { - return this.initialValueInputTargets.find((input) => - (input.dataset.referrerField === fieldName)); - } - - // Finds the value field input based on a field name. - // - // The value field input holds the current value of a progress field. - private findValueInput(fieldName:string):HTMLInputElement|undefined { - return this.progressInputTargets.find((input) => - (input.name === fieldName) || (input.name === `work_package[${fieldName}]`)); - } - - private isTouchedAndEmpty(fieldName:string) { - return this.isTouched(fieldName) && this.isValueEmpty(fieldName); - } - - private isTouched(fieldName:string) { - return this.touchedFields.has(fieldName); - } - - private isInitialValueEmpty(fieldName:string) { - const valueInput = this.findInitialValueInput(fieldName); - return valueInput?.value === ''; - } - - private isValueEmpty(fieldName:string) { - const valueInput = this.findValueInput(fieldName); - return valueInput?.value === ''; - } - - private isValueSet(fieldName:string) { - const valueInput = this.findValueInput(fieldName); - return valueInput !== undefined && valueInput.value !== ''; - } - - private markTouched(fieldName:string) { - this.touchedFields.add(fieldName); - this.updateTouchedFieldHiddenInputs(); - } - - private markUntouched(fieldName:string) { - this.touchedFields.delete(fieldName); - this.updateTouchedFieldHiddenInputs(); - } - - private updateTouchedFieldHiddenInputs() { - this.touchedFieldInputTargets.forEach((input) => { - const fieldName = input.dataset.referrerField; - if (fieldName) { - input.value = this.isTouched(fieldName) ? 'true' : 'false'; - } - }); + private areBothTouched(fieldName1:string, fieldName2:string):boolean { + return super.isTouched(fieldName1) && super.isTouched(fieldName2); } } diff --git a/lookbook/previews/open_project/common/datepicker_preview/single.html.erb b/lookbook/previews/open_project/common/datepicker_preview/single.html.erb index bd6afbdcdc15..70df33c0ebb0 100644 --- a/lookbook/previews/open_project/common/datepicker_preview/single.html.erb +++ b/lookbook/previews/open_project/common/datepicker_preview/single.html.erb @@ -1 +1 @@ -<%= tag :'opce-single-date-picker', value: %> +<%= tag :'opce-basic-single-date-picker', value: %>