Files
Christophe Bliard eae369ec1f [68402] Correctly recompute duration for automatic work packages with children
https://community.openproject.org/wp/68402

There was a bug where when switching to manual mode and clearing the
dates, the controller would also clear the duration and consider it as
being done by the user (touched field). Then when switching to automatic
mode, before calling the SetAttributesService, start date and finish
date would be discarded because the work package is in automatic mode.

The SetAttributesService would then think that the duration being empty
was done by the user, and would then also unset the finish date. That
lead to the incorrect display of the dates and duration in the date
picker: they would appear as empty instead of having the dates inherited
from the child.

Fix is to also discard the duration if the work package is automatically
scheduled and has children.
2025-10-17 15:57:40 +02:00

305 lines
8.9 KiB
Ruby

# 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
ERROR_PRONE_ATTRIBUTES = %i[start_date
due_date
duration].freeze
layout false
before_action :find_work_package, except: %i[new create preview]
authorization_checked! :show, :preview, :update, :edit, :new, :create
attr_accessor :work_package
def show
set_date_attributes_to_work_package
render_form
end
def new
make_fake_initial_work_package
set_date_attributes_to_work_package
render_form
end
def edit
set_date_attributes_to_work_package
render_form
end
def preview
if params[:work_package_id]
find_work_package
else
make_fake_initial_work_package
end
set_date_attributes_to_work_package
render_form(preview: true)
end
def create
make_fake_initial_work_package
service_call = set_date_attributes_to_work_package
if service_call.errors
.map(&:attribute)
.intersect?(ERROR_PRONE_ATTRIBUTES)
render_form(status: :unprocessable_entity)
else
render json: {
startDate: @work_package.start_date,
dueDate: @work_package.due_date,
duration: @work_package.duration,
scheduleManually: @work_package.schedule_manually,
includeNonWorkingDays: if @work_package.ignore_non_working_days.nil?
false
else
@work_package.ignore_non_working_days
end
}
end
end
def update
wp_params = work_package_datepicker_params
wp_params = manage_params_for_automatic_mode(wp_params)
service_call = WorkPackages::UpdateService
.new(user: current_user,
model: @work_package)
.call(wp_params)
if service_call.success?
head :ok
else
render_form(status: :unprocessable_entity)
end
end
private
def render_form(status: :ok, preview: false)
render :show,
locals: {
work_package:,
schedule_manually:,
focused_field:,
touched_field_map:,
date_mode:,
preview:
},
status:
end
def focused_field
return params[:focused_field] if params[:focused_field].present?
trigger = params[:field]
# For automatic scheduling, we focus the due date initially and do not switch to start date after touching it
if !ActiveModel::Type::Boolean.new.cast(schedule_manually) && trigger != "duration"
return :due_date
end
# Decide which field to focus next
case trigger
when "startDate"
:start_date
when "work_package[start_date]"
handle_focus_order_for_fields(:start_date, :due_date)
when "work_package[duration]", "duration"
:duration
when "dueDate"
:due_date
when "work_package[due_date]"
handle_focus_order_for_fields(:due_date, :start_date)
else
:start_date
end
end
def find_work_package
@work_package = WorkPackage.visible.find(params[:work_package_id])
end
def touched_field_map
if params[:work_package]
params.require(:work_package)
.slice("schedule_manually_touched",
"ignore_non_working_days_touched",
"start_date_touched",
"due_date_touched",
"duration_touched")
.transform_values { it == "true" }
.permit!
else
{}
end
end
def schedule_manually
find_if_present(params[:schedule_manually]) ||
find_if_present(params.dig(:work_package, :schedule_manually)) ||
work_package.schedule_manually
end
def date_mode
# Once in range mode, always in range mode
return params[:date_mode] if params[:date_mode].present? && params[:date_mode] == "range"
if work_package.start_date.nil? || work_package.due_date.nil?
"single"
else
"range"
end
end
def find_if_present(value)
value.presence
end
def work_package_datepicker_params
if params[:work_package]
handle_milestone_dates
handle_form_cleared
params.require(:work_package)
.slice(*allowed_touched_params)
.merge(schedule_manually:, date_mode:, triggering_field: params[:triggering_field])
.permit!
else
{}
end
end
def allowed_touched_params
allowed_params.filter { touched?(it) }
end
def allowed_params
%i[schedule_manually ignore_non_working_days start_date due_date duration]
end
def touched?(field)
touched_field_map[:"#{field}_touched"]
end
def make_fake_initial_work_package
initial_params = params.require(:work_package)
.require(:initial)
.permit(:start_date, :due_date, :duration, :ignore_non_working_days)
@work_package = WorkPackage.new(initial_params)
@work_package.clear_changes_information
end
def set_date_attributes_to_work_package
wp_params = work_package_datepicker_params
wp_params = manage_params_for_automatic_mode(wp_params)
if wp_params.present?
WorkPackages::SetAttributesService
.new(user: current_user,
model: @work_package,
contract_class:)
.call(wp_params)
end
end
def contract_class
if @work_package.new_record?
WorkPackages::CreateContract
else
WorkPackages::UpdateContract
end
end
def handle_milestone_dates
if work_package.is_milestone? && params.require(:work_package).has_key?(:start_date)
# Set the dueDate as the SetAttributesService will otherwise throw an error because the fields do not match
params.require(:work_package)[:due_date] = params.require(:work_package)[:start_date]
params.require(:work_package)[:due_date_touched] = "true"
end
end
def handle_form_cleared
touched_params = params.require(:work_package).slice(*allowed_touched_params)
if two_fields_cleared?(touched_params)
# If two fields are already manually cleared, we assume that the user wants to clear the whole form
params_array = %i[start_date due_date duration]
params_array.each do |param|
if touched_params[param].nil?
params.require(:work_package)[param] = ""
params.require(:work_package)["#{param}_touched"] = "true"
end
end
end
end
def two_fields_cleared?(wp_params)
start_date = wp_params[:start_date]
due_date = wp_params[:due_date]
duration = wp_params[:duration]
# Check which params are set to an empty string
empty_params = [start_date, due_date, duration].select { |param| param == "" }
# Check which param was not touched
missing_param = [start_date, due_date, duration].select(&:nil?)
# If two values are deleted and one is untouched, return tru
empty_params.length == 2 && missing_param.length == 1
end
def handle_focus_order_for_fields(trigger_field, alternative_field)
if !!params[:work_package][:"#{trigger_field}_touched"] && params[:work_package][:"#{trigger_field}"].blank?
# Special case, when deleting a value: we want to keep the focus on that field instead of moving to the next field
trigger_field
else
alternative_field
end
end
def manage_params_for_automatic_mode(wp_params)
return wp_params if wp_params["schedule_manually"] != "false"
# For WP with children the dates and duration are always fixed
return wp_params.without("start_date", "due_date", "duration") if work_package.children.any?
# the start should be preserved and will thus be send as a parameter
wp_params["start_date"] = work_package.start_date.to_s
wp_params
end
end