diff --git a/app/components/concerns/op_turbo/streamable.rb b/app/components/concerns/op_turbo/streamable.rb index dfad026a7825..89180a56ddc2 100644 --- a/app/components/concerns/op_turbo/streamable.rb +++ b/app/components/concerns/op_turbo/streamable.rb @@ -33,6 +33,8 @@ class MissingComponentWrapper < StandardError; end # rubocop:enable OpenProject/AddPreviewForViewComponent INLINE_ACTIONS = %i[dialog flash].freeze + # Turbo allows the response method for these actions only: + ACTIONS_WITH_METHOD = %i[update replace].freeze extend ActiveSupport::Concern @@ -43,7 +45,7 @@ def wrapper_key end included do - def render_as_turbo_stream(view_context:, action: :update) + def render_as_turbo_stream(view_context:, action: :update, method: nil) case action when :update, *INLINE_ACTIONS @inner_html_only = true @@ -63,8 +65,13 @@ def render_as_turbo_stream(view_context:, action: :update) "Wrap your component in a `component_wrapper` block in order to use turbo-stream methods" end + if method && !action.in?(ACTIONS_WITH_METHOD) + raise ArgumentError, "The #{action} action does not supports a method" + end + OpTurbo::StreamComponent.new( action:, + method:, target: wrapper_key, template: ).render_in(view_context) diff --git a/app/contracts/model_contract.rb b/app/contracts/model_contract.rb index efa06281f331..35cdf5b58c62 100644 --- a/app/contracts/model_contract.rb +++ b/app/contracts/model_contract.rb @@ -48,7 +48,7 @@ class ModelContract < BaseContract # This of course is only true if that contract validates the model and # if the model has an errors object. def valid?(context = nil) - model.valid? if validate_model? + model.valid?(context) if validate_model? contract_valid?(context, clear_errors: !validate_model?) end @@ -61,7 +61,8 @@ def valid?(context = nil) # Clearing would then be done in the #valid? method by calling model.valid? # * Checks for readonly attributes being changed def contract_valid?(context = nil, clear_errors: false) - current_context, self.validation_context = validation_context, context # rubocop:disable Style/ParallelAssignment + current_context = validation_context + self.validation_context = context errors.clear if clear_errors diff --git a/app/contracts/project_life_cycle_steps/base_contract.rb b/app/contracts/project_life_cycle_steps/base_contract.rb new file mode 100644 index 000000000000..a8289ceb27a8 --- /dev/null +++ b/app/contracts/project_life_cycle_steps/base_contract.rb @@ -0,0 +1,79 @@ +#-- 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 ProjectLifeCycleSteps + class BaseContract < ::ModelContract + validate :select_custom_fields_permission + validate :consecutive_steps_have_increasing_dates + + def valid?(context = :saving_life_cycle_steps) + super + end + + def select_custom_fields_permission + return if user.allowed_in_project?(:edit_project_stages_and_gates, model) + + errors.add :base, :error_unauthorized + end + + def consecutive_steps_have_increasing_dates + # Filter out steps with missing dates before proceeding with comparison + filtered_steps = model.available_life_cycle_steps.select(&:start_date) + + # Only proceed with comparisons if there are at least 2 valid steps + return if filtered_steps.size < 2 + + # Compare consecutive steps in pairs + filtered_steps.each_cons(2) do |previous_step, current_step| + if start_date_for(current_step) <= end_date_for(previous_step) + step = previous_step.is_a?(Project::Stage) ? "Stage" : "Gate" + field = current_step.is_a?(Project::Stage) ? :date_range : :date + model.errors.import( + current_step.errors.add(field, :non_continuous_dates, step:), + attribute: :"available_life_cycle_steps.#{field}" + ) + end + end + end + + private + + def start_date_for(step) + step.start_date + end + + def end_date_for(step) + case step + when Project::Gate + step.date + when Project::Stage + step.end_date || step.start_date # Use the start_date as fallback for single date stages + end + end + end +end diff --git a/app/contracts/project_life_cycle_steps/update_contract.rb b/app/contracts/project_life_cycle_steps/update_contract.rb new file mode 100644 index 000000000000..28b22227eaa4 --- /dev/null +++ b/app/contracts/project_life_cycle_steps/update_contract.rb @@ -0,0 +1,32 @@ +#-- 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 ProjectLifeCycleSteps + class UpdateContract < BaseContract + end +end diff --git a/app/controllers/concerns/op_turbo/component_stream.rb b/app/controllers/concerns/op_turbo/component_stream.rb index 6cc60cd36918..f7cd5e1369bc 100644 --- a/app/controllers/concerns/op_turbo/component_stream.rb +++ b/app/controllers/concerns/op_turbo/component_stream.rb @@ -42,23 +42,24 @@ def respond_to_with_turbo_streams(status: turbo_status, &format_block) alias_method :respond_with_turbo_streams, :respond_to_with_turbo_streams - def update_via_turbo_stream(component:, status: :ok) - modify_via_turbo_stream(component:, action: :update, status:) + def update_via_turbo_stream(component:, status: :ok, method: nil) + modify_via_turbo_stream(component:, action: :update, status:, method:) end - def replace_via_turbo_stream(component:, status: :ok) - modify_via_turbo_stream(component:, action: :replace, status:) + def replace_via_turbo_stream(component:, status: :ok, method: nil) + modify_via_turbo_stream(component:, action: :replace, status:, method:) end def remove_via_turbo_stream(component:, status: :ok) modify_via_turbo_stream(component:, action: :remove, status:) end - def modify_via_turbo_stream(component:, action:, status:) + def modify_via_turbo_stream(component:, action:, status:, method: nil) @turbo_status = status turbo_streams << component.render_as_turbo_stream( view_context:, - action: + action:, + method: ) end diff --git a/app/forms/application_form.rb b/app/forms/application_form.rb index cb52b5b980f0..2fe86546d088 100644 --- a/app/forms/application_form.rb +++ b/app/forms/application_form.rb @@ -40,6 +40,11 @@ def url_helpers Rails.application.routes.url_helpers end + # @return [ActionView::Base] the view helper instance + def helpers + @view_context.helpers + end + # @return [ActiveRecord::Base] the model instance given to the form builder def model @builder.object diff --git a/app/forms/projects/life_cycles/form.rb b/app/forms/projects/life_cycles/form.rb new file mode 100644 index 000000000000..1bd83b7b1f61 --- /dev/null +++ b/app/forms/projects/life_cycles/form.rb @@ -0,0 +1,104 @@ +#-- 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 Projects::LifeCycles + class Form < ApplicationForm + form do |f| + life_cycle_input(f) + end + + private + + def life_cycle_input(form) + case model + when Project::Stage + multi_value_life_cycle_input(form) + when Project::Gate + single_value_life_cycle_input(form) + else + raise NotImplementedError, "Unknown life cycle definition type #{model.class.name}" + end + end + + def qa_field_name + "life-cycle-step-#{model.id}" + end + + def base_input_attributes + { + label: "#{icon} #{text}".html_safe, # rubocop:disable Rails/OutputSafety + leading_visual: { icon: :calendar }, + datepicker_options: { + inDialog: true, + data: { action: "change->overview--project-life-cycles-form#handleChange" } + }, + wrapper_data_attributes: { + "qa-field-name": qa_field_name + } + } + end + + def single_value_life_cycle_input(form) + input_attributes = { name: :date, value: model.date } + + form.single_date_picker **base_input_attributes, **input_attributes + end + + def multi_value_life_cycle_input(form) + value = [model.start_date, model.end_date].compact.join(" - ") + + input_attributes = { name: :date_range, value: } + if model.working_days_count + input_attributes[:caption] = + I18n.t("project_stage.working_days_count", count: model.working_days_count) + end + + form.range_date_picker **base_input_attributes, **input_attributes + end + + def text + model.name + end + + def icon + icon_name = case model + when Project::Stage + :"git-commit" + when Project::Gate + :diamond + else + raise NotImplementedError, "Unknown model #{model.class} to render a LifeCycleForm with" + end + + render Primer::Beta::Octicon.new(icon: icon_name, classes: icon_color_class) + end + + def icon_color_class + helpers.hl_inline_class("life_cycle_step_definition", model.definition) + end + end +end diff --git a/app/models/permitted_params.rb b/app/models/permitted_params.rb index ba22823578ba..d145da84d561 100644 --- a/app/models/permitted_params.rb +++ b/app/models/permitted_params.rb @@ -290,6 +290,12 @@ def project whitelist.merge(custom_field_values(:project)) end + def project_life_cycles + params.require(:project).permit( + available_life_cycle_steps_attributes: %i[id date date_range] + ) + end + def project_custom_field_project_mapping params.require(:project_custom_field_project_mapping) .permit(*self.class.permitted_attributes[:project_custom_field_project_mapping]) diff --git a/app/models/project.rb b/app/models/project.rb index 89191c1eba8b..5694839fb130 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -87,6 +87,14 @@ class Project < ApplicationRecord has_many :project_storages, dependent: :destroy, class_name: "Storages::ProjectStorage" has_many :storages, through: :project_storages has_many :life_cycle_steps, class_name: "Project::LifeCycleStep", dependent: :destroy + has_many :available_life_cycle_steps, + -> { active.eager_load(:definition).order(position: :asc) }, + class_name: "Project::LifeCycleStep", + inverse_of: :project, + dependent: :destroy + + accepts_nested_attributes_for :available_life_cycle_steps + validates_associated :available_life_cycle_steps, on: :saving_life_cycle_steps store_attribute :settings, :deactivate_work_package_attachments, :boolean diff --git a/app/models/project/gate.rb b/app/models/project/gate.rb index 46ee6fffdb74..8c49a5360c5c 100644 --- a/app/models/project/gate.rb +++ b/app/models/project/gate.rb @@ -31,7 +31,6 @@ class Project::Gate < Project::LifeCycleStep # This ensures the type cannot be changed after initialising the class. validates :type, inclusion: { in: %w[Project::Gate], message: :must_be_a_gate } - validates :date, presence: true validate :end_date_not_allowed def end_date_not_allowed @@ -39,4 +38,8 @@ def end_date_not_allowed errors.add(:base, :end_date_not_allowed) end end + + def not_set? + date.blank? + end end diff --git a/app/models/project/life_cycle_step.rb b/app/models/project/life_cycle_step.rb index d4ffcf438427..28b6a1fa9a8f 100644 --- a/app/models/project/life_cycle_step.rb +++ b/app/models/project/life_cycle_step.rb @@ -27,24 +27,25 @@ #++ class Project::LifeCycleStep < ApplicationRecord - belongs_to :project, optional: false + belongs_to :project, optional: false, inverse_of: :available_life_cycle_steps belongs_to :definition, optional: false, class_name: "Project::LifeCycleStepDefinition" has_many :work_packages, inverse_of: :project_life_cycle_step, dependent: :nullify + delegate :name, :position, to: :definition + attr_readonly :definition_id, :type validates :type, inclusion: { in: %w[Project::Stage Project::Gate], message: :must_be_a_stage_or_gate } + validate :validate_type_and_class_name_are_identical - def initialize(*args) - if instance_of? Project::LifeCycleStep - # Do not allow directly instantiating this class - raise NotImplementedError, "Cannot instantiate the base Project::LifeCycleStep class directly. " \ - "Use Project::Stage or Project::Gate instead." - end + scope :active, -> { where(active: true) } - super + def validate_type_and_class_name_are_identical + if type != self.class.name + errors.add(:type, :type_and_class_name_mismatch) + end end def column_name diff --git a/app/models/project/life_cycle_step_definition.rb b/app/models/project/life_cycle_step_definition.rb index 5f1181f326d0..0aadf22d0846 100644 --- a/app/models/project/life_cycle_step_definition.rb +++ b/app/models/project/life_cycle_step_definition.rb @@ -37,21 +37,12 @@ class Project::LifeCycleStepDefinition < ApplicationRecord validates :name, presence: true validates :type, inclusion: { in: %w[Project::StageDefinition Project::GateDefinition], message: :must_be_a_stage_or_gate } + validate :validate_type_and_class_name_are_identical attr_readonly :type acts_as_list - def initialize(*args) - if instance_of? Project::LifeCycleStepDefinition - # Do not allow directly instantiating this class - raise NotImplementedError, "Cannot instantiate the base Project::LifeCycleStepDefinition class directly. " \ - "Use Project::StageDefinition or Project::GateDefinition instead." - end - - super - end - def step_class raise NotImplementedError end @@ -59,4 +50,12 @@ def step_class def column_name "lcsd_#{id}" end + + private + + def validate_type_and_class_name_are_identical + if type != self.class.name + errors.add(:type, :type_and_class_name_mismatch) + end + end end diff --git a/app/models/project/stage.rb b/app/models/project/stage.rb index b49ca7787b71..2ee54f26cd3e 100644 --- a/app/models/project/stage.rb +++ b/app/models/project/stage.rb @@ -29,5 +29,36 @@ class Project::Stage < Project::LifeCycleStep # This ensures the type cannot be changed after initialising the class. validates :type, inclusion: { in: %w[Project::Stage], message: :must_be_a_stage } - validates :start_date, :end_date, presence: true + validate :validate_date_range + + def working_days_count + return nil if not_set? + + Day.working.from_range(from: start_date, to: end_date).count + end + + def date_range=(param) + self.start_date, self.end_date = param.split(" - ") + self.end_date ||= start_date # Allow single dates as range + end + + def not_set? + start_date.blank? || end_date.blank? + end + + def range_set? + !not_set? + end + + def range_incomplete? + start_date.blank? ^ end_date.blank? + end + + def validate_date_range + if range_incomplete? + errors.add(:date_range, :incomplete) + elsif range_set? && (start_date > end_date) + errors.add(:date_range, :start_date_must_be_before_end_date) + end + end end diff --git a/app/services/project_life_cycle_steps/preview_attributes_service.rb b/app/services/project_life_cycle_steps/preview_attributes_service.rb new file mode 100644 index 000000000000..0070cabfe246 --- /dev/null +++ b/app/services/project_life_cycle_steps/preview_attributes_service.rb @@ -0,0 +1,47 @@ +#-- 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 ProjectLifeCycleSteps + class PreviewAttributesService < ::BaseServices::SetAttributes + def perform(*) + super.tap do |service_call| + clear_unchanged_fields(service_call) + end + end + + private + + def clear_unchanged_fields(service_call) + service_call + .result + .available_life_cycle_steps + .select(&:not_set?) + .each { _1.errors.clear } + end + end +end diff --git a/app/services/project_life_cycle_steps/set_attributes_service.rb b/app/services/project_life_cycle_steps/set_attributes_service.rb new file mode 100644 index 000000000000..c1603d42603b --- /dev/null +++ b/app/services/project_life_cycle_steps/set_attributes_service.rb @@ -0,0 +1,32 @@ +#-- 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 ProjectLifeCycleSteps + class SetAttributesService < ::BaseServices::SetAttributes + end +end diff --git a/app/services/project_life_cycle_steps/update_service.rb b/app/services/project_life_cycle_steps/update_service.rb new file mode 100644 index 000000000000..a8a5a33aa923 --- /dev/null +++ b/app/services/project_life_cycle_steps/update_service.rb @@ -0,0 +1,32 @@ +#-- 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 ProjectLifeCycleSteps + class UpdateService < ::BaseServices::Update + end +end diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index e1a2f82b1ebc..8d369cee59bd 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -133,6 +133,18 @@ permissible_on: :project, require: :member + map.permission :view_project_stages_and_gates, + {}, + permissible_on: :project, + dependencies: :view_project + + map.permission :edit_project_stages_and_gates, + {}, + permissible_on: :project, + require: :member, + dependencies: :view_project_stages_and_gates, + contract_actions: { projects: %i[update] } + map.permission :select_project_life_cycle, { "projects/settings/life_cycle_steps": %i[index toggle enable_all disable_all] diff --git a/config/locales/en.yml b/config/locales/en.yml index 711b60a0e861..9d8817ed940e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -425,7 +425,10 @@ en: no_results_content_text: Create a new version storage: no_results_title_text: There is no additional recorded disk space consumed by this project. - + project_stage: + working_days_count: + one: "Duration: %{count} working day" + other: "Duration: %{count} working days" lists: create: success: "The modified list has been saved as a new list" @@ -1209,16 +1212,38 @@ en: attributes: type: must_be_a_stage_or_gate: "must be either Project::StageDefinition or Project::GateDefinition" + type_and_class_name_mismatch: "must match the instantiated class name" + project/gate_definition: + attributes: + type: + type_and_class_name_mismatch: "must be a Project::GateDefinition" + project/stage_definition: + attributes: + type: + type_and_class_name_mismatch: "must be a Project::StageDefinition" project/life_cycle_step: attributes: type: must_be_a_stage_or_gate: "must be either Project::Stage or Project::Gate" must_be_a_stage: "must be a Project::Stage" must_be_a_gate: "must be a Project::Gate" + type_and_class_name_mismatch: "must match the instantiated class name" project/gate: attributes: base: - end_date_not_allowed: "Cannot assign `end_date` to a Project::Gate" + end_date_not_allowed: "Cannot assign end date to a Project::Gate" + type: + type_and_class_name_mismatch: "must be a Project::Gate" + date: + non_continuous_dates: "can't be earlier than the previous %{step}'s end date." + project/stage: + attributes: + date_range: + start_date_must_be_before_end_date: "start date must be before the end date." + non_continuous_dates: "can't be earlier than the previous %{step}'s end date." + incomplete: "is incomplete." + type: + type_and_class_name_mismatch: "must be a Project::Stage" query: attributes: project: @@ -3220,6 +3245,7 @@ en: permission_edit_own_time_entries: "Edit own time logs" permission_edit_project: "Edit project" permission_edit_project_attributes: "Edit project attributes" + permission_edit_project_stages_and_gates: "Edit project stages and gates" permission_edit_reportings: "Edit reportings" permission_edit_time_entries: "Edit time logs for other users" permission_edit_timelines: "Edit timelines" @@ -3275,6 +3301,7 @@ en: permission_work_package_assigned_explanation: "Work packages can be assigned to users and groups in possession of this role in the respective project" permission_view_project_activity: "View project activity" permission_view_project_attributes: "View project attributes" + permission_view_project_stages_and_gates: "View project stages and gates" permission_save_bcf_queries: "Save BCF queries" permission_manage_public_bcf_queries: "Manage public BCF queries" permission_edit_attribute_help_texts: "Edit attribute help texts" diff --git a/db/migrate/20241126111225_add_project_life_cycle_step_roles.rb b/db/migrate/20241126111225_add_project_life_cycle_step_roles.rb new file mode 100644 index 000000000000..e618a7696ae7 --- /dev/null +++ b/db/migrate/20241126111225_add_project_life_cycle_step_roles.rb @@ -0,0 +1,39 @@ +#-- 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. +#++ + +require Rails.root.join("db/migrate/migration_utils/permission_adder") + +class AddProjectLifeCycleStepRoles < ActiveRecord::Migration[7.1] + def change + ::Migration::MigrationUtils::PermissionAdder + .add(:view_project, :view_project_stages_and_gates) + + ::Migration::MigrationUtils::PermissionAdder + .add(:edit_project, :edit_project_stages_and_gates) + end +end diff --git a/frontend/src/app/features/overview/overview.component.ts b/frontend/src/app/features/overview/overview.component.ts index 742139d19362..86e75ede5e39 100644 --- a/frontend/src/app/features/overview/overview.component.ts +++ b/frontend/src/app/features/overview/overview.component.ts @@ -1,3 +1,31 @@ +//-- 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 } from '@angular/core'; import { GridPageComponent } from 'core-app/shared/components/grids/grid/page/grid-page.component'; import { GRID_PROVIDERS } from 'core-app/shared/components/grids/grid/grid.component'; @@ -17,15 +45,34 @@ export class OverviewComponent extends GridPageComponent { } protected isTurboFrameSidebarEnabled():boolean { - const sidebarEnabledTag:HTMLMetaElement|null = document.querySelector('meta[name="sidebar_enabled"]'); - return sidebarEnabledTag?.dataset.enabled === 'true'; + return this.isCustomFieldsSidebarEnabled() || this.isLifeCyclesSidebarEnabled(); + } + + protected isCustomFieldsSidebarEnabled():boolean { + const customFieldsSidebarEnabledTag:HTMLMetaElement|null = document.querySelector('meta[name="custom_fields_sidebar_enabled"]'); + + return customFieldsSidebarEnabledTag?.dataset.enabled === 'true'; + } + + protected isLifeCyclesSidebarEnabled():boolean { + const lifeCyclesSidebarEnabledTag:HTMLMetaElement|null = document.querySelector('meta[name="life_cycles_sidebar_enabled"]'); + + return lifeCyclesSidebarEnabledTag?.dataset.enabled === 'true'; + } + + protected lifeCyclesSidebarSrc():string { + return `${this.pathHelper.staticBase}/projects/${this.currentProject.identifier ?? ''}/project_life_cycles_sidebar`; + } + + protected lifeCyclesSidebarId():string { + return 'project-life-cycles-sidebar'; } - protected turboFrameSidebarSrc():string { + protected projectCustomFieldsSidebarSrc():string { return `${this.pathHelper.staticBase}/projects/${this.currentProject.identifier ?? ''}/project_custom_fields_sidebar`; } - protected turboFrameSidebarId():string { + protected projectCustomFieldsSidebarId():string { return 'project-custom-fields-sidebar'; } diff --git a/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.html b/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.html index 6c9a4f8699d3..9fd117204e52 100644 --- a/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.html +++ b/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.html @@ -6,8 +6,10 @@ data-test-selector="op-basic-range-date-picker" [ngClass]="inputClassNames" [attr.data-value]="value" + [attr.data-action]="dataAction" [id]="id" [name]="name" + [attr.name]="name" [required]="required" [disabled]="disabled" [ngModel]="stringValue" @@ -46,6 +48,8 @@ [attr.data-value]="stringValue" [id]="id" [name]="name" + [attr.name]="name" + [attr.data-action]="dataAction" [ngModel]="stringValue" /> diff --git a/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.ts b/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.ts index 35f9f3494f69..d9c791513467 100644 --- a/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.ts +++ b/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.ts @@ -115,6 +115,10 @@ export class OpBasicRangeDatePickerComponent implements OnInit, ControlValueAcce @Input() inputClassNames = ''; + @Input() inDialog = false; + + @Input() dataAction = ''; + @ViewChild('input') input:ElementRef; stringValue = ''; @@ -199,6 +203,7 @@ export class OpBasicRangeDatePickerComponent implements OnInit, ControlValueAcce !!this.minimalDate && dayElem.dateObj <= this.minimalDate, ); }, + static: this.inDialog, }, this.input.nativeElement as HTMLInputElement, ); diff --git a/frontend/src/app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component.html b/frontend/src/app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component.html index e15dd1bf3ebc..c5381fee5c80 100644 --- a/frontend/src/app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component.html +++ b/frontend/src/app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component.html @@ -7,6 +7,7 @@ [ngClass]="inputClassNames" [ngModel]="value" [attr.data-value]="value" + [attr.data-action]="dataAction" [id]="id" [name]="name" [attr.name]="name" @@ -24,8 +25,10 @@ data-filter--filters-form-target="singleDay" [ngClass]="inputClassNames" [attr.data-value]="value" + [attr.data-action]="dataAction" [id]="id" [name]="name" + [attr.name]="name" [required]="required" [disabled]="disabled" [ngModel]="value" diff --git a/frontend/src/app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component.ts b/frontend/src/app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component.ts index 130ab9e59b10..1ecc03b2fe52 100644 --- a/frontend/src/app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component.ts +++ b/frontend/src/app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component.ts @@ -52,7 +52,6 @@ import { DayElement } from 'flatpickr/dist/types/instance'; import { populateInputsFromDataset } from '../../dataset-inputs'; import { DeviceService } from 'core-app/core/browser/device.service'; - @Component({ selector: 'op-basic-single-date-picker', templateUrl: './basic-single-date-picker.component.html', @@ -96,6 +95,10 @@ export class OpBasicSingleDatePickerComponent implements ControlValueAccessor, O @Input() remoteFieldKey = null; + @Input() inDialog = false; + + @Input() dataAction = ''; + @ViewChild('input') input:ElementRef; mobile = false; @@ -179,6 +182,7 @@ export class OpBasicSingleDatePickerComponent implements ControlValueAccessor, O !!this.minimalDate && dayElem.dateObj <= this.minimalDate, ); }, + static: this.inDialog, }, this.input.nativeElement as HTMLInputElement, ); 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..904eb15044cd 100644 --- a/frontend/src/app/shared/components/datepicker/styles/datepicker.modal.sass +++ b/frontend/src/app/shared/components/datepicker/styles/datepicker.modal.sass @@ -1,6 +1,5 @@ @import '../../app/spot/styles/sass/variables' @import '../../global_styles/openproject/variables' - .op-datepicker-modal display: flex flex-direction: column @@ -88,3 +87,8 @@ &--date-form &:only-child grid-column: 1 / 3 + +.flatpickr-wrapper + // Make flatpickr behave correctly when it is instantiated + // inside a dialog using the static: true option. + width: 100% diff --git a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html index 01a5002f85d8..f3c82b9e54c7 100644 --- a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html +++ b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html @@ -17,7 +17,27 @@

- + + + + + + + + + + + + + diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index 2ef53fc4dd50..b458b00a2e9d 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -32,6 +32,12 @@ input border-radius: var(--borderRadius-medium) + // Currently we cannot morph the angular datepicker input field, and we have no way to set the + // validation error border on the input. However we can morph the input's wrapper element, thus + // adding this parent wrapper rule we can display the red border on the input. + &-input-wrap[invalid='true'] input:not(:focus) + border-color: var(--control-borderColor-danger) + .UnderlineNav @include no-visible-scroll-bar margin-bottom: 12px diff --git a/frontend/src/stimulus/controllers/dynamic/overview/project-life-cycles-form.controller.ts b/frontend/src/stimulus/controllers/dynamic/overview/project-life-cycles-form.controller.ts new file mode 100644 index 000000000000..1cc2421b730f --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/overview/project-life-cycles-form.controller.ts @@ -0,0 +1,58 @@ +/* + * -- 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'; + +export default class ProjectLifeCyclesFormController extends Controller { + static targets = ['form']; + + declare readonly formTarget:HTMLFormElement; + + handleChange(event:Event) { + const target = event.target as HTMLElement; + const previewUrl = this.formTarget.dataset.previewUrl; + + if (!previewUrl || this.datePickerVisible(target)) { + return; // flatpickr is still open, do not submit yet. + } + + const form = this.formTarget; + form.action = previewUrl; + + form.requestSubmit(); + } + + datePickerVisible(element:HTMLElement) { + const nextElement = element.nextElementSibling; + return nextElement + && nextElement.classList.contains('flatpickr-calendar') + && nextElement.classList.contains('open'); + } +} diff --git a/frontend/src/turbo/turbo-global-listeners.ts b/frontend/src/turbo/turbo-global-listeners.ts index 1b715fe5c1ba..8ad1914e8fdc 100644 --- a/frontend/src/turbo/turbo-global-listeners.ts +++ b/frontend/src/turbo/turbo-global-listeners.ts @@ -64,4 +64,13 @@ export function addTurboGlobalListeners() { activateFlashNotice(); activateFlashError(); }); + + document.addEventListener('turbo:before-morph-element', (event) => { + const element = event.target as HTMLElement; + + // In case the element is an OpenProject custom dom element, morphing is prevented. + if (element.tagName.toUpperCase().startsWith('OPCE-')) { + event.preventDefault(); + } + }); } diff --git a/lib/primer/open_project/forms/date_picker.html.erb b/lib/primer/open_project/forms/date_picker.html.erb new file mode 100644 index 000000000000..7b55923f0a48 --- /dev/null +++ b/lib/primer/open_project/forms/date_picker.html.erb @@ -0,0 +1,53 @@ +<%#-- 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. + +++#%> + +<%= render(FormControl.new(input: @input, tag: :"primer-datepicker-field")) do %> + <%= content_tag(:div, **@field_wrap_arguments) do %> + <%# leading spinner implies a leading visual %> + <% if @input.leading_visual || @input.leading_spinner? %> + + <%= render(Primer::Beta::Octicon.new(**@input.leading_visual, data: { target: "primer-text-field.leadingVisual" })) %> + <% if @input.leading_spinner? %> + <%= render(Primer::Beta::Spinner.new(size: :small, hidden: true, data: { target: "primer-text-field.leadingSpinner" })) %> + <% end %> + + <% end %> + <%= render Primer::ConditionalWrapper.new(condition: @input.auto_check_src, tag: "auto-check", csrf: auto_check_authenticity_token, src: @input.auto_check_src) do %> + <%= angular_component_tag @datepicker_options.fetch(:component), + inputs: @datepicker_options.merge( + id: @datepicker_options.fetch(:id) { builder.field_id(@input.name) }, + name: @datepicker_options.fetch(:name) { builder.field_name(@input.name) }, + value: @datepicker_options.fetch(:value) { @input.input_arguments[:value] || builder.object&.send(@input.name) }, + inputClassNames: @datepicker_options.fetch(:class) { @input.input_arguments[:class] }, + dataAction: @datepicker_options.fetch(:data, {}).fetch(:action, nil) + ) + %> + <% end %> + <% end %> +<% end %> diff --git a/lib/primer/open_project/forms/date_picker.rb b/lib/primer/open_project/forms/date_picker.rb new file mode 100644 index 000000000000..6c7de2fc830c --- /dev/null +++ b/lib/primer/open_project/forms/date_picker.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Primer + module OpenProject + module Forms + # :nodoc: + class DatePicker < Primer::Forms::TextField + include AngularHelper + + def initialize(input:, datepicker_options:) + super(input:) + + @field_wrap_arguments[:invalid] = true if @input.invalid? + @datepicker_options = datepicker_options + end + end + end + end +end diff --git a/lib/primer/open_project/forms/dsl/input_methods.rb b/lib/primer/open_project/forms/dsl/input_methods.rb index e2151a300dd5..eca21f89ef75 100644 --- a/lib/primer/open_project/forms/dsl/input_methods.rb +++ b/lib/primer/open_project/forms/dsl/input_methods.rb @@ -6,31 +6,39 @@ module Forms module Dsl module InputMethods def autocompleter(**, &) - add_input AutocompleterInput.new(builder: @builder, form: @form, **, &) + add_input AutocompleterInput.new(builder:, form:, **, &) end - def work_package_autocompleter(**, &) - add_input WorkPackageAutocompleterInput.new(builder: @builder, form: @form, **, &) + def color_select_list(**, &) + add_input ColorSelectInput.new(builder:, form:, **, &) + end + + def html_content(**, &) + add_input HtmlContentInput.new(builder:, form:, **, &) end def project_autocompleter(**, &) - add_input ProjectAutocompleterInput.new(builder: @builder, form: @form, **, &) + add_input ProjectAutocompleterInput.new(builder:, form:, **, &) + end + + def range_date_picker(**) + add_input RangeDatePickerInput.new(builder:, form:, **) end def rich_text_area(**) - add_input RichTextAreaInput.new(builder: @builder, form: @form, **) + add_input RichTextAreaInput.new(builder:, form:, **) end - def storage_manual_project_folder_selection(**) - add_input StorageManualProjectFolderSelectionInput.new(builder: @builder, form: @form, **) + def single_date_picker(**) + add_input SingleDatePickerInput.new(builder:, form:, **) end - def color_select_list(**, &) - add_input ColorSelectInput.new(builder:, form:, **, &) + def storage_manual_project_folder_selection(**) + add_input StorageManualProjectFolderSelectionInput.new(builder:, form:, **) end - def html_content(**, &) - add_input HtmlContentInput.new(builder: @builder, form: @form, **, &) + def work_package_autocompleter(**, &) + add_input WorkPackageAutocompleterInput.new(builder:, form:, **, &) end end end diff --git a/lib/primer/open_project/forms/dsl/range_date_picker_input.rb b/lib/primer/open_project/forms/dsl/range_date_picker_input.rb new file mode 100644 index 000000000000..0b79a36b068c --- /dev/null +++ b/lib/primer/open_project/forms/dsl/range_date_picker_input.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Primer + module OpenProject + module Forms + module Dsl + class RangeDatePickerInput < SingleDatePickerInput + def derive_datepicker_options(options) + options.reverse_merge( + component: "opce-range-date-picker" + ) + end + + def type + :range_date_picker + end + end + end + end + end +end diff --git a/lib/primer/open_project/forms/dsl/single_date_picker_input.rb b/lib/primer/open_project/forms/dsl/single_date_picker_input.rb new file mode 100644 index 000000000000..7a356bdfba4c --- /dev/null +++ b/lib/primer/open_project/forms/dsl/single_date_picker_input.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Primer + module OpenProject + module Forms + module Dsl + class SingleDatePickerInput < Primer::Forms::Dsl::TextFieldInput + attr_reader :datepicker_options + + def initialize(name:, label:, datepicker_options:, **system_arguments) + @datepicker_options = derive_datepicker_options(datepicker_options) + + super(name:, label:, **system_arguments) + end + + def derive_datepicker_options(options) + options.reverse_merge( + component: "opce-single-date-picker" + ) + end + + def to_component + DatePicker.new(input: self, datepicker_options:) + end + + def type + :single_date_picker + end + end + end + end + end +end diff --git a/lookbook/previews/open_project/common/datepicker_preview.rb b/lookbook/previews/open_project/common/datepicker_preview.rb index c9bdeaca41ef..4359c5d1dc24 100644 --- a/lookbook/previews/open_project/common/datepicker_preview.rb +++ b/lookbook/previews/open_project/common/datepicker_preview.rb @@ -28,8 +28,10 @@ class DatepickerPreview < Lookbook::Preview # before using or contributing to date pickers. # # @param value date - def single(value: Time.zone.today.iso8601) - render_with_template(locals: { value: }) + # @param in_dialog toggle + # @param icon [Symbol] octicon + def single(value: Time.zone.today, in_dialog: false, icon: :calendar) + render_with_template(locals: { value:, in_dialog:, icon: }) end ## @@ -48,8 +50,10 @@ def single(value: Time.zone.today.iso8601) # before using or contributing to date pickers. # # @param value text - def range(value: "#{Time.zone.today.iso8601} - #{Time.zone.today.iso8601}") - render_with_template(locals: { value: }) + # @param in_dialog toggle + # @param icon [Symbol] octicon + def range(value: "#{Time.zone.today.iso8601} - #{Time.zone.today.iso8601}", in_dialog: false, icon: :calendar) + render_with_template(locals: { value:, in_dialog:, icon: }) end end end diff --git a/lookbook/previews/open_project/common/datepicker_preview/range.html.erb b/lookbook/previews/open_project/common/datepicker_preview/range.html.erb index 559e35bba55a..ca2e4295e87e 100644 --- a/lookbook/previews/open_project/common/datepicker_preview/range.html.erb +++ b/lookbook/previews/open_project/common/datepicker_preview/range.html.erb @@ -1 +1,58 @@ -<%= tag :'opce-range-date-picker', value: %> +<% + the_form = Class.new(ApplicationForm) do + form do |query_form| + query_form.range_date_picker( + name: :date, + label: 'Date', + leading_visual: { icon: }, + value: value, + datepicker_options: { inDialog: in_dialog } + ) + + query_form.range_date_picker( + name: :date, + label: 'Date', + leading_visual: { icon: }, + value: value, + datepicker_options: { inDialog: in_dialog } + ) + + query_form.range_date_picker( + name: :date, + label: 'Date', + leading_visual: { icon: }, + value: value, + datepicker_options: { inDialog: in_dialog } + ) + end + end +%> + +<% if in_dialog %> + <%= render(Primer::Alpha::Dialog.new(title: "Dialog Title", + size: :large, + open: true, + id: "my-dialog")) do |d| %> + <% d.with_show_button { "Show dialog" } %> + <% d.with_header(variant: :medium) %> + + <%= render(Primer::Alpha::Dialog::Body.new) do + primer_form_with( + url: '/abc', + id: "my-form") do |f| + render(the_form.new(f)) + end + end %> + + <%= d.with_footer do %> + <%= render(Primer::Beta::Button.new(data: { "close-dialog-id": "my-dialog" })) { I18n.t(:button_cancel) } %> + <%= render(Primer::Beta::Button.new(scheme: :primary, type: :submit, form: "my-form")) { I18n.t(:button_apply) } %> + <% end %> + <% end %> +<% else %> + <%= primer_form_with( + url: '/abc', + id: "my-form") do |f| + render(the_form.new(f)) + end %> +<% end %> 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..8f5f34ef2765 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,58 @@ -<%= tag :'opce-single-date-picker', value: %> +<% + the_form = Class.new(ApplicationForm) do + form do |query_form| + query_form.single_date_picker( + name: :date, + label: 'Date', + leading_visual: { icon: }, + value: value.iso8601, + datepicker_options: { inDialog: in_dialog } + ) + + query_form.single_date_picker( + name: :date, + label: 'Date', + leading_visual: { icon: }, + value: value.iso8601, + datepicker_options: { inDialog: in_dialog } + ) + + query_form.single_date_picker( + name: :date, + label: 'Date', + leading_visual: { icon: }, + value: value.iso8601, + datepicker_options: { inDialog: in_dialog } + ) + end + end +%> + +<% if in_dialog %> + <%= render(Primer::Alpha::Dialog.new(title: "Dialog Title", + size: :large, + open: true, + id: "my-dialog")) do |d| %> + <% d.with_show_button { "Show dialog" } %> + <% d.with_header(variant: :medium) %> + + <%= render(Primer::Alpha::Dialog::Body.new) do + primer_form_with( + url: '/abc', + id: "my-form") do |f| + render(the_form.new(f)) + end + end %> + + <%= d.with_footer do %> + <%= render(Primer::Beta::Button.new(data: { "close-dialog-id": "my-dialog" })) { I18n.t(:button_cancel) } %> + <%= render(Primer::Beta::Button.new(scheme: :primary, type: :submit, form: "my-form")) { I18n.t(:button_apply) } %> + <% end %> + <% end %> +<% else %> + <%= primer_form_with( + url: '/abc', + id: "my-form") do |f| + render(the_form.new(f)) + end %> +<% end %> diff --git a/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb new file mode 100644 index 000000000000..506d398e3a76 --- /dev/null +++ b/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb @@ -0,0 +1,26 @@ +<%= helpers.angular_component_tag 'opce-custom-modal-overlay' %> +<%= + component_wrapper do + primer_form_with( + id: "project-life-cycles-edit-form", + model:, + method: :put, + data: { + "controller": "overview--project-life-cycles-form", + "overview--project-life-cycles-form-target": "form", + "application-target": "dynamic", + turbo: true, + turbo_stream: true, + preview_url: project_life_cycles_form_path(project_id: model.id), + "test-selector": "async-dialog-content" + }, + url: update_project_life_cycles_path(project_id: model.id), + ) do |f| + render(Primer::Forms::SpacingWrapper.new) do + f.fields_for(:available_life_cycle_steps) do |life_cycle_form| + render(Projects::LifeCycles::Form.new(life_cycle_form)) + end + end + end + end +%> diff --git a/modules/overviews/app/components/project_life_cycles/sections/edit_component.rb b/modules/overviews/app/components/project_life_cycles/sections/edit_component.rb new file mode 100644 index 000000000000..69e395d772f7 --- /dev/null +++ b/modules/overviews/app/components/project_life_cycles/sections/edit_component.rb @@ -0,0 +1,37 @@ +#-- 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 ProjectLifeCycles + module Sections + class EditComponent < ApplicationComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + end + end +end diff --git a/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.html.erb new file mode 100644 index 000000000000..a2eab213ea09 --- /dev/null +++ b/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.html.erb @@ -0,0 +1,32 @@ +<%= + render(Primer::Alpha::Dialog.new(title: t("label_life_cycle_step_plural"), + size: :large, + id: dialog_id)) do |d| + d.with_header(variant: :large) + d.with_body(classes: "Overlay-body_autocomplete_height") do + render(::ProjectLifeCycles::Sections::EditComponent.new(model)) + end + d.with_footer do + component_collection do |footer_collection| + footer_collection.with_component(Primer::ButtonComponent.new( + data: { + "close-dialog-id": dialog_id + } + )) do + t("button_cancel") + end + footer_collection.with_component(Primer::ButtonComponent.new( + scheme: :primary, + type: :submit, + form: "project-life-cycles-edit-form", + data: { + test_selector: 'save-project-life-cycles-button', + turbo: true + } + )) do + t("button_save") + end + end + end + end +%> diff --git a/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.rb b/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.rb new file mode 100644 index 000000000000..45f2689421ea --- /dev/null +++ b/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.rb @@ -0,0 +1,41 @@ +#-- 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 ProjectLifeCycles + module Sections + class EditDialogComponent < ApplicationComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + def dialog_id + "edit-project-life-cycles-dialog" + end + end + end +end diff --git a/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.html.erb new file mode 100644 index 000000000000..ea3f87c3b8ab --- /dev/null +++ b/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.html.erb @@ -0,0 +1,28 @@ +<%= + flex_layout(align_items: :flex_start, + justify_content: :space_between, + classes: 'op-project-life-cycle-container', + data: { + test_selector: "project-life-cycle-#{model.id}" + }) do |life_cycle_container| + life_cycle_container.with_row(mb: 1, flex_layout: true, ) do |row| + row.with_column(mr: 1, classes: icon_color_class) do + render Primer::Beta::Octicon.new(icon:) + end + + row.with_column do + render(Primer::Beta::Text.new(font_weight: :bold)) do + text + end + end + end + + life_cycle_container.with_row(w: :full) do + if not_set? + render(Primer::Beta::Text.new()) { t('placeholders.default') } + else + render_value + end + end + end +%> diff --git a/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.rb b/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.rb new file mode 100644 index 000000000000..43e1f19da34f --- /dev/null +++ b/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.rb @@ -0,0 +1,75 @@ +#-- 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 ProjectLifeCycles + module Sections + module ProjectLifeCycles + class ShowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + + private + + def not_set? + model.not_set? + end + + def render_value + render(Primer::Beta::Text.new) do + concat [ + model.start_date, + model.end_date + ] + .compact + .map { |d| helpers.format_date(d) } + .join(" - ") + end + end + + def icon + case model + when Project::Stage + :"git-commit" + when Project::Gate + :diamond + else + raise NotImplementedError, "Unknown model #{model.class} to render a LifeCycleForm with" + end + end + + def icon_color_class + helpers.hl_inline_class("life_cycle_step_definition", model.definition) + end + + def text + model.name + end + end + end + end +end diff --git a/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb new file mode 100644 index 000000000000..c8a801c9f9dd --- /dev/null +++ b/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb @@ -0,0 +1,34 @@ +<%= + component_wrapper do + render(Primer::OpenProject::SidePanel::Section.new( + classes: 'op-project-life-cyle-section-container', + test_selector: "project-life-cycle-section" + )) do |section| + section.with_title { t("label_life_cycle_step_plural") } + + if allowed_to_edit? + section.with_action_icon( + icon: :pencil, + tag: :a, + href: project_life_cycles_dialog_path(project_id: @project.id), + data: { + controller: 'async-dialog' + }, + test_selector: "project-life-cycles-edit-button", + aria: { label: I18n.t(:label_edit) } + ) + end + + flex_layout do |details_container| + @life_cycle_steps.each_with_index do |life_cycle_step, i| + margin = i == @life_cycle_steps.size - 1 ? 0 : 3 + details_container.with_row(mb: margin) do + render(ProjectLifeCycles::Sections::ProjectLifeCycles::ShowComponent.new( + life_cycle_step + )) + end + end + end + end + end +%> diff --git a/modules/overviews/app/components/project_life_cycles/sections/show_component.rb b/modules/overviews/app/components/project_life_cycles/sections/show_component.rb new file mode 100644 index 000000000000..f848609e596f --- /dev/null +++ b/modules/overviews/app/components/project_life_cycles/sections/show_component.rb @@ -0,0 +1,50 @@ +#-- 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 ProjectLifeCycles + module Sections + class ShowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(project:) + super + + @project = project + @life_cycle_steps = @project.available_life_cycle_steps + end + + private + + def allowed_to_edit? + User.current.allowed_in_project?(:edit_project_stages_and_gates, @project) + end + end + end +end diff --git a/modules/overviews/app/components/project_life_cycles/side_panel_component.html.erb b/modules/overviews/app/components/project_life_cycles/side_panel_component.html.erb new file mode 100644 index 000000000000..763a30ed12c3 --- /dev/null +++ b/modules/overviews/app/components/project_life_cycles/side_panel_component.html.erb @@ -0,0 +1,10 @@ +<%= + component_wrapper do + render(Primer::OpenProject::SidePanel.new( + spacious: true, + test_selector: "project-life-cycles-sidebar-async-content" + )) do |panel| + panel.with_section(ProjectLifeCycles::Sections::ShowComponent.new(project: @project)) + end + end +%> diff --git a/modules/overviews/app/components/project_life_cycles/side_panel_component.rb b/modules/overviews/app/components/project_life_cycles/side_panel_component.rb new file mode 100644 index 000000000000..d837969ffa31 --- /dev/null +++ b/modules/overviews/app/components/project_life_cycles/side_panel_component.rb @@ -0,0 +1,41 @@ +#-- 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 ProjectLifeCycles + class SidePanelComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(project:) + super + + @project = project + end + end +end diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index 9f1e34476aff..679701ae7bca 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -1,3 +1,31 @@ +# -- 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 ::Overviews class OverviewsController < ::Grids::BaseInProjectController include OpTurbo::ComponentStream @@ -27,7 +55,6 @@ def project_custom_field_section_dialog def update_project_custom_values section = find_project_custom_field_section - service_call = ::Projects::UpdateService .new( user: current_user, @@ -49,6 +76,50 @@ def update_project_custom_values respond_to_with_turbo_streams(status: service_call.success? ? :ok : :unprocessable_entity) end + def project_life_cycles_sidebar + render :project_life_cycles_sidebar, layout: false + end + + def project_life_cycles_dialog + respond_with_dialog( + ProjectLifeCycles::Sections::EditDialogComponent.new(@project) + ) + end + + def project_life_cycles_form + service_call = ::ProjectLifeCycleSteps::PreviewAttributesService + .new(user: current_user, + model: @project, + contract_class: ProjectLifeCycleSteps::UpdateContract) + .call(permitted_params.project_life_cycles) + + update_via_turbo_stream( + component: ProjectLifeCycles::Sections::EditComponent.new(service_call.result), + method: "morph" + ) + # TODO: :unprocessable_entity is not nice, change the dialog logic to accept :ok + # without dismissing the dialog, alternatively use turbo frames instead of streams. + respond_to_with_turbo_streams(status: :unprocessable_entity) + end + + def update_project_life_cycles + service_call = ::ProjectLifeCycleSteps::UpdateService + .new(user: current_user, model: @project) + .call(permitted_params.project_life_cycles) + + if service_call.success? + update_via_turbo_stream( + component: ProjectLifeCycles::SidePanelComponent.new(project: @project) + ) + else + update_via_turbo_stream( + component: ProjectLifeCycles::Sections::EditComponent.new(service_call.result) + ) + end + + respond_to_with_turbo_streams(status: service_call.success? ? :ok : :unprocessable_entity) + end + def jump_to_project_menu_item if params[:jump] # try to redirect to the requested menu item @@ -63,9 +134,13 @@ def find_project_custom_field_section end def set_sidebar_enabled - @sidebar_enabled = + @custom_fields_sidebar_enabled = User.current.allowed_in_project?(:view_project_attributes, @project) && @project.project_custom_fields.visible.any? + @life_cycles_sidebar_enabled = + OpenProject::FeatureDecisions.stages_and_gates_active? && + User.current.allowed_in_project?(:view_project_stages_and_gates, @project) && + @project.life_cycle_steps.active.any? end def handle_errors(project_with_errors, section) diff --git a/modules/overviews/app/views/overviews/overviews/project_life_cycles_sidebar.html.erb b/modules/overviews/app/views/overviews/overviews/project_life_cycles_sidebar.html.erb new file mode 100644 index 000000000000..1b05538bb3c7 --- /dev/null +++ b/modules/overviews/app/views/overviews/overviews/project_life_cycles_sidebar.html.erb @@ -0,0 +1,31 @@ +<%#-- 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. + +++#%> +<%= content_tag("turbo-frame", id: "project-life-cycles-sidebar") do %> + <%= render(ProjectLifeCycles::SidePanelComponent.new(project: @project)) %> +<% end %> diff --git a/modules/overviews/app/views/overviews/overviews/show.html.erb b/modules/overviews/app/views/overviews/overviews/show.html.erb index cce3768ff982..a323d0ca45e8 100644 --- a/modules/overviews/app/views/overviews/overviews/show.html.erb +++ b/modules/overviews/app/views/overviews/overviews/show.html.erb @@ -1,5 +1,6 @@ <% content_for :header_tags do %> - + + <% end -%> <%= diff --git a/modules/overviews/config/routes.rb b/modules/overviews/config/routes.rb index 9a007f2a0e7e..c6a9a0b1b446 100644 --- a/modules/overviews/config/routes.rb +++ b/modules/overviews/config/routes.rb @@ -9,5 +9,14 @@ as: :project_custom_field_section_dialog put "projects/:project_id/update_project_custom_values/:section_id", to: "overviews/overviews#update_project_custom_values", as: :update_project_custom_values + + get "projects/:project_id/project_life_cycles_sidebar", to: "overviews/overviews#project_life_cycles_sidebar", + as: :project_life_cycles_sidebar + get "projects/:project_id/project_life_cycles_dialog", to: "overviews/overviews#project_life_cycles_dialog", + as: :project_life_cycles_dialog + put "projects/:project_id/project_life_cycles_form", to: "overviews/overviews#project_life_cycles_form", + as: :project_life_cycles_form + put "projects/:project_id/update_project_life_cycles", to: "overviews/overviews#update_project_life_cycles", + as: :update_project_life_cycles end end diff --git a/modules/overviews/lib/overviews/engine.rb b/modules/overviews/lib/overviews/engine.rb index eb934b7c033b..f9e5988c90b8 100644 --- a/modules/overviews/lib/overviews/engine.rb +++ b/modules/overviews/lib/overviews/engine.rb @@ -63,6 +63,20 @@ class Engine < ::Rails::Engine "overviews/overviews/update_project_custom_values" ) + OpenProject::AccessControl.permission(:view_project_stages_and_gates) + .controller_actions + .push( + "overviews/overviews/project_life_cycles_sidebar" + ) + + OpenProject::AccessControl.permission(:edit_project_stages_and_gates) + .controller_actions + .push( + "overviews/overviews/project_life_cycles_dialog", + "overviews/overviews/project_life_cycles_form", + "overviews/overviews/update_project_life_cycles" + ) + OpenProject::AccessControl.permission(:view_work_packages) .controller_actions .push( diff --git a/spec/contracts/project_life_cycle_steps/base_contract_spec.rb b/spec/contracts/project_life_cycle_steps/base_contract_spec.rb new file mode 100644 index 000000000000..e0df64f40504 --- /dev/null +++ b/spec/contracts/project_life_cycle_steps/base_contract_spec.rb @@ -0,0 +1,152 @@ +# 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. +#++ + +require "spec_helper" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe ProjectLifeCycleSteps::BaseContract do + include_context "ModelContract shared context" + + let(:contract) { described_class.new(project, user) } + let(:project) { build_stubbed(:project) } + + context "with authorized user" do + let(:user) { build_stubbed(:user) } + let(:project) { build_stubbed(:project, available_life_cycle_steps: steps) } + let(:steps) { [] } + + before do + mock_permissions_for(user) do |mock| + mock.allow_in_project(:edit_project_stages_and_gates, project:) + end + end + + it_behaves_like "contract is valid" + include_examples "contract reuses the model errors" + + describe "validations" do + describe "#consecutive_steps_have_increasing_dates" do + let(:step1) { build_stubbed(:project_gate, start_date: Date.new(2024, 1, 1)) } + let(:step2) { build_stubbed(:project_stage, start_date: Date.new(2024, 2, 1), end_date: Date.new(2024, 2, 28)) } + let(:step3) { build_stubbed(:project_gate, start_date: Date.new(2024, 3, 1), end_date: Date.new(2024, 3, 15)) } + let(:steps) { [step1, step2, step3] } + + context "when no steps are present" do + let(:steps) { [] } + + it_behaves_like "contract is valid" + end + + context "when only one step is present" do + let(:steps) { [step1] } + + it_behaves_like "contract is valid" + end + + context "when steps have valid and increasing dates" do + let(:steps) { [step1, step2] } + + it_behaves_like "contract is valid" + end + + context "when steps have decreasing dates" do + context "and the erroneous step is a Gate" do + let(:steps) { [step3, step1] } + + it_behaves_like "contract is invalid", + "available_life_cycle_steps.date": :non_continuous_dates + + it "adds an error to the decreasing step" do + contract.validate + expect(step1.errors.symbols_for(:date)).to include(:non_continuous_dates) + end + end + + context "and the erroneous step is a Stage" do + let(:steps) { [step3, step2] } + + it_behaves_like "contract is invalid", + "available_life_cycle_steps.date_range": :non_continuous_dates + + it "adds an error to the decreasing step" do + contract.validate + expect(step2.errors.symbols_for(:date_range)).to include(:non_continuous_dates) + end + end + end + + context "when steps with identical dates" do + let(:step4) { build_stubbed(:project_gate, start_date: Date.new(2024, 1, 1)) } + let(:steps) { [step1, step4] } + + it_behaves_like "contract is invalid", + "available_life_cycle_steps.date": :non_continuous_dates + end + + context "when a step has missing start dates" do + let(:step_missing_dates) { build_stubbed(:project_stage, start_date: nil, end_date: nil) } + + context "and the other steps have increasing dates" do + let(:steps) { [step1, step_missing_dates, step2] } + + it_behaves_like "contract is valid" + end + + context "and the other steps have decreasing dates" do + let(:steps) { [step2, step_missing_dates, step1] } + + it_behaves_like "contract is invalid", + "available_life_cycle_steps.date": :non_continuous_dates + + it "adds an error to the decreasing step" do + contract.validate + expect(step1.errors.symbols_for(:date)).to include(:non_continuous_dates) + end + end + end + end + + describe "triggering validations on the model" do + it "sets the :saving_life_cycle_steps validation context" do + allow(project).to receive(:valid?) + + contract.validate + expect(project).to have_received(:valid?).with(:saving_life_cycle_steps) + end + end + end + end + + context "with unauthorized user" do + let(:user) { build_stubbed(:user) } + + it_behaves_like "contract user is unauthorized" + end +end diff --git a/spec/factories/project_life_cycle_step_factory.rb b/spec/factories/project_life_cycle_step_factory.rb index cc64e09b02e7..465dd702bfc0 100644 --- a/spec/factories/project_life_cycle_step_factory.rb +++ b/spec/factories/project_life_cycle_step_factory.rb @@ -31,6 +31,10 @@ project active { true } + trait :skip_validate do + to_create { |instance| instance.save(validate: false) } + end + factory :project_stage, class: "Project::Stage" do definition factory: :project_stage_definition start_date { Date.current - 2.days } diff --git a/spec/features/projects/copy_spec.rb b/spec/features/projects/copy_spec.rb index 2577e7c5a00c..1c2ae9555aeb 100644 --- a/spec/features/projects/copy_spec.rb +++ b/spec/features/projects/copy_spec.rb @@ -391,7 +391,7 @@ overview_page = Pages::Projects::Show.new(copied_project) overview_page.visit! - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do # User has no permission to edit project attributes. expect(page).to have_no_css("[data-test-selector='project-custom-field-section-edit-button']") # The custom fields are still copied from the parent project. diff --git a/spec/features/projects/life_cycle/overview_page/dialog/permission_spec.rb b/spec/features/projects/life_cycle/overview_page/dialog/permission_spec.rb new file mode 100644 index 000000000000..debb041b9fbb --- /dev/null +++ b/spec/features/projects/life_cycle/overview_page/dialog/permission_spec.rb @@ -0,0 +1,85 @@ +#-- 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. +#++ + +require "spec_helper" +require_relative "../shared_context" + +RSpec.describe "Edit project stages and gates on project overview page", :js, :with_cuprite, + with_flag: { stages_and_gates: true } do + include_context "with seeded projects and stages and gates" + shared_let(:user) { create(:user) } + let(:overview_page) { Pages::Projects::Show.new(project) } + let(:permissions) { [] } + + current_user { user } + + before do + mock_permissions_for(user) do |mock| + mock.allow_in_project(*permissions, project:) # any project + end + overview_page.visit_page + end + + describe "with insufficient View Stages and Gates permissions" do + let(:permissions) { %i[view_project] } + + it "does not show the attributes sidebar" do + overview_page.expect_no_visible_sidebar + end + end + + describe "with sufficient View Stages and Gates permissions" do + let(:permissions) { %i[view_project view_project_stages_and_gates] } + + it "shows the attributes sidebar" do + overview_page.within_life_cycles_sidebar do + expect(page).to have_text("Project lifecycle") + end + end + end + + describe "with Edit project permissions" do + let(:permissions) { %i[view_project view_project_stages_and_gates edit_project] } + + it "does not show the edit buttons" do + overview_page.within_life_cycles_sidebar do + expect(page).to have_no_css("[data-test-selector='project-life-cycles-edit-button']") + end + end + end + + describe "with sufficient Edit Stages and Gates permissions" do + let(:permissions) { %i[view_project view_project_stages_and_gates edit_project edit_project_stages_and_gates] } + + it "shows the edit buttons" do + overview_page.within_life_cycles_sidebar do + expect(page).to have_css("[data-test-selector='project-life-cycles-edit-button']") + end + end + end +end diff --git a/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb b/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb new file mode 100644 index 000000000000..59431c3ac7fa --- /dev/null +++ b/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb @@ -0,0 +1,155 @@ +#-- 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. +#++ + +require "spec_helper" +require_relative "../shared_context" + +RSpec.describe "Edit project stages and gates on project overview page", :js, :with_cuprite, + with_flag: { stages_and_gates: true } do + include_context "with seeded projects and stages and gates" + shared_let(:overview) { create :overview, project: } + + let(:overview_page) { Pages::Projects::Show.new(project) } + + current_user { admin } + + before do + overview_page.visit_page + end + + describe "with the dialog open" do + context "when all LifeCycleSteps are blank" do + before do + Project::LifeCycleStep.update_all(start_date: nil, end_date: nil) + end + + it "shows all the Project::LifeCycleSteps without a value" do + dialog = overview_page.open_edit_dialog_for_life_cycles + + dialog.expect_input("Initiating", value: "", type: :stage, position: 1) + dialog.expect_input("Ready for Planning", value: "", type: :gate, position: 2) + dialog.expect_input("Planning", value: "", type: :stage, position: 3) + dialog.expect_input("Ready for Executing", value: "", type: :gate, position: 4) + dialog.expect_input("Executing", value: "", type: :stage, position: 5) + dialog.expect_input("Ready for Closing", value: "", type: :gate, position: 6) + dialog.expect_input("Closing", value: "", type: :stage, position: 7) + + # Saving the dialog is successful + dialog.submit + dialog.expect_closed + + # Sidebar displays the same empty values + project_life_cycles.each do |life_cycle| + overview_page.within_life_cycle_container(life_cycle) do + expect(page).to have_text "-" + end + end + end + end + + context "when all LifeCycleSteps have a value" do + it "shows all the Project::LifeCycleSteps and updates them correctly" do + dialog = overview_page.open_edit_dialog_for_life_cycles + + expect_angular_frontend_initialized + + project.available_life_cycle_steps.each do |step| + dialog.expect_input_for(step) + end + + initiating_dates = [start_date - 1.week, start_date] + + retry_block do + # Retrying due to a race condition between filling the input vs submitting the form preview. + original_dates = [life_cycle_initiating.start_date, life_cycle_initiating.end_date] + dialog.set_date_for(life_cycle_initiating, value: original_dates) + dialog.set_date_for(life_cycle_initiating, value: initiating_dates) + + dialog.expect_caption(life_cycle_initiating, text: "Duration: 8 working days") + end + + ready_for_planning_date = start_date + 1.day + dialog.set_date_for(life_cycle_ready_for_planning, value: ready_for_planning_date) + dialog.expect_no_caption(life_cycle_ready_for_planning) + + # Saving the dialog is successful + dialog.submit + dialog.expect_closed + + # Sidebar is refreshed with the updated values + expected_date_range = initiating_dates.map { |date| date.strftime("%m/%d/%Y") }.join(" - ") + overview_page.within_life_cycle_container(life_cycle_initiating) do + expect(page).to have_text expected_date_range + end + + overview_page.within_life_cycle_container(life_cycle_ready_for_planning) do + expect(page).to have_text ready_for_planning_date.strftime("%m/%d/%Y") + end + end + + it "shows the validation errors" do + expect_angular_frontend_initialized + wait_for_network_idle + + dialog = overview_page.open_edit_dialog_for_life_cycles + + expected_text = "Date can't be earlier than the previous Stage's end date." + + # Cycling is required so we always select a different date on the datepicker, + # making sure the change event is triggered. + cycled_days = [0, 1].cycle + + # Retrying due to a race condition between filling the input vs submitting the form preview. + retry_block do + value = start_date + cycled_days.next.days + dialog.set_date_for(life_cycle_ready_for_planning, value:) + + dialog.expect_validation_message(life_cycle_ready_for_planning, text: expected_text) + end + + # Saving the dialog fails + dialog.submit + dialog.expect_open + + # The validation message is kept after the unsuccessful save attempt + dialog.expect_validation_message(life_cycle_ready_for_planning, text: expected_text) + + retry_block do + # The validation message is cleared when date is changed + value = start_date + 2.days + cycled_days.next.days + dialog.set_date_for(life_cycle_ready_for_planning, value:) + dialog.expect_no_validation_message(life_cycle_ready_for_planning) + end + + # Saving the dialog is successful + dialog.submit + dialog.expect_closed + end + end + end +end diff --git a/spec/features/projects/life_cycle/overview_page/shared_context.rb b/spec/features/projects/life_cycle/overview_page/shared_context.rb new file mode 100644 index 000000000000..67c480b13f80 --- /dev/null +++ b/spec/features/projects/life_cycle/overview_page/shared_context.rb @@ -0,0 +1,116 @@ +#-- 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. +#++ + +RSpec.shared_context "with seeded projects and stages and gates" do + shared_let(:project) { create(:project, name: "Foo project", identifier: "foo-project") } + shared_let(:standard) { create(:standard_global_role) } + shared_let(:admin) { create(:admin) } + + shared_let(:life_cycle_initiating_definition) do + create :project_stage_definition, name: "Initiating" + end + shared_let(:life_cycle_ready_for_planning_definition) do + create :project_gate_definition, name: "Ready for Planning" + end + shared_let(:life_cycle_planning_definition) do + create :project_stage_definition, name: "Planning" + end + shared_let(:life_cycle_ready_for_executing_definition) do + create :project_gate_definition, name: "Ready for Executing" + end + shared_let(:life_cycle_executing_definition) do + create :project_stage_definition, name: "Executing" + end + shared_let(:life_cycle_ready_for_closing_definition) do + create :project_gate_definition, name: "Ready for Closing" + end + shared_let(:life_cycle_closing_definition) do + create :project_stage_definition, name: "Closing" + end + + let(:start_date) { Time.zone.today.next_week } + + let(:life_cycle_initiating) do + create :project_stage, + definition: life_cycle_initiating_definition, + start_date:, + end_date: start_date + 1.day, + project: + end + let(:life_cycle_ready_for_planning) do + create :project_gate, + definition: life_cycle_ready_for_planning_definition, + date: start_date + 2.days, + project: + end + let(:life_cycle_planning) do + create :project_stage, + definition: life_cycle_planning_definition, + start_date: start_date + 4.days, + end_date: start_date + 7.days, + project: + end + let(:life_cycle_ready_for_executing) do + create :project_gate, + definition: life_cycle_ready_for_executing_definition, + date: start_date + 8.days, + project: + end + let(:life_cycle_executing) do + create :project_stage, + definition: life_cycle_executing_definition, + start_date: start_date + 9.days, + end_date: start_date + 10.days, + project: + end + let(:life_cycle_ready_for_closing) do + create :project_gate, + definition: life_cycle_ready_for_closing_definition, + date: start_date + 11.days, + project: + end + let(:life_cycle_closing) do + create :project_stage, + definition: life_cycle_closing_definition, + start_date: start_date + 14.days, + end_date: start_date + 18.days, + project: + end + + let!(:project_life_cycles) do + [ + life_cycle_initiating, + life_cycle_ready_for_planning, + life_cycle_planning, + life_cycle_ready_for_executing, + life_cycle_executing, + life_cycle_ready_for_closing, + life_cycle_closing + ] + end +end diff --git a/spec/features/projects/life_cycle/overview_page/sidebar_spec.rb b/spec/features/projects/life_cycle/overview_page/sidebar_spec.rb new file mode 100644 index 000000000000..66298f4b1c4a --- /dev/null +++ b/spec/features/projects/life_cycle/overview_page/sidebar_spec.rb @@ -0,0 +1,151 @@ +#-- 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. +#++ + +require "spec_helper" +require_relative "shared_context" + +RSpec.describe "Show project life cycles on project overview page", :js, :with_cuprite, with_flag: { stages_and_gates: true } do + include_context "with seeded projects and stages and gates" + + let(:overview_page) { Pages::Projects::Show.new(project) } + + current_user { admin } + + it "does show the sidebar" do + overview_page.visit_page + overview_page.expect_visible_sidebar + end + + context "when stages and gates are disabled", with_flag: { stages_and_gates: false } do + it "does not show the sidebar" do + overview_page.visit_page + overview_page.expect_no_visible_sidebar + end + end + + context "when all stages and gates are disabled for this project" do + before do + project_life_cycles.each { |p| p.toggle!(:active) } + end + + it "does not show the sidebar" do + overview_page.visit_page + overview_page.expect_no_visible_sidebar + end + end + + describe "with correct order and scoping" do + it "shows the project stages and gates in the correct order" do + overview_page.visit_page + + overview_page.within_life_cycles_sidebar do + expected_stages = [ + "Initiating", + "Ready for Planning", + "Planning", + "Ready for Executing", + "Executing", + "Ready for Closing", + "Closing" + ] + fields = page.all(".op-project-life-cycle-container > div:first-child") + expect(fields.map(&:text)).to eq(expected_stages) + end + + life_cycle_ready_for_executing_definition.move_to_bottom + + overview_page.visit_page + + overview_page.within_life_cycles_sidebar do + expected_stages = [ + "Initiating", + "Ready for Planning", + "Planning", + "Executing", + "Ready for Closing", + "Closing", + "Ready for Executing" + ] + fields = page.all(".op-project-life-cycle-container > div:first-child") + expect(fields.map(&:text)).to eq(expected_stages) + end + end + + it "does not show stages and gates not enabled for this project in a sidebar" do + life_cycle_ready_for_executing.toggle!(:active) + + overview_page.visit_page + + overview_page.within_life_cycles_sidebar do + expect(page).to have_no_text life_cycle_ready_for_executing.name + end + end + end + + describe "with correct values" do + describe "with values set" do + it "shows the correct value for the project custom field if given" do + overview_page.visit_page + + overview_page.within_life_cycles_sidebar do + project_life_cycles.each do |life_cycle| + overview_page.within_life_cycle_container(life_cycle) do + expected_date = if life_cycle.is_a? Project::Stage + [ + life_cycle.start_date.strftime("%m/%d/%Y"), + life_cycle.end_date.strftime("%m/%d/%Y") + ].join(" - ") + else + life_cycle.start_date.strftime("%m/%d/%Y") + end + expect(page).to have_text expected_date + end + end + end + end + end + + describe "with no values" do + before do + Project::LifeCycleStep.update_all(start_date: nil, end_date: nil) + end + + it "shows the correct value for the project custom field if given" do + overview_page.visit_page + + overview_page.within_life_cycles_sidebar do + project_life_cycles.each do |life_cycle| + overview_page.within_life_cycle_container(life_cycle) do + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + end + end + end +end diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb index df7bc3a8464c..579893825534 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb @@ -31,7 +31,6 @@ RSpec.describe "Edit project custom fields on project overview page", :js do include_context "with seeded projects, members and project custom fields" - let(:overview_page) { Pages::Projects::Show.new(project) } describe "with insufficient View attributes permissions" do @@ -52,7 +51,7 @@ end it "shows the attributes sidebar" do - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do expect(page).to have_text("Input fields") end end @@ -65,7 +64,7 @@ end it "does not show the edit buttons" do - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do expect(page).to have_no_css("[data-test-selector='project-custom-field-section-edit-button']") end end @@ -81,7 +80,7 @@ end it "does not show the edit buttons" do - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do expect(page).to have_no_css("[data-test-selector='project-custom-field-section-edit-button']") end end @@ -94,7 +93,7 @@ end it "shows the edit buttons" do - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do expect(page).to have_css("[data-test-selector='project-custom-field-section-edit-button']", count: 3) end end diff --git a/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb index 23bb5d72ff2b..1f96df0b8a06 100644 --- a/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb @@ -50,7 +50,7 @@ it "shows the project custom field sections in the correct order" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do sections = page.all(".op-project-custom-field-section-container") expect(sections.size).to eq(3) @@ -64,7 +64,7 @@ overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do sections = page.all(".op-project-custom-field-section-container") expect(sections.size).to eq(3) @@ -78,7 +78,7 @@ it "shows the project custom fields in the correct order within the sections" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_section_container(section_for_input_fields) do fields = page.all(".op-project-custom-field-container") @@ -118,7 +118,7 @@ overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_section_container(section_for_input_fields) do fields = page.all(".op-project-custom-field-container") @@ -140,7 +140,7 @@ overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do expect(page).to have_no_text "String field enabled for other project" end end @@ -152,7 +152,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(boolean_project_custom_field) do expect(page).to have_text "Boolean field" expect(page).to have_text "Yes" @@ -170,7 +170,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(boolean_project_custom_field) do expect(page).to have_text "Boolean field" expect(page).to have_text "No" @@ -187,7 +187,7 @@ it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(boolean_project_custom_field) do expect(page).to have_text "Boolean field" expect(page).to have_text I18n.t("placeholders.default") @@ -200,7 +200,7 @@ overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(boolean_project_custom_field) do expect(page).to have_text "Boolean field" expect(page).to have_text I18n.t("placeholders.default") @@ -211,7 +211,7 @@ overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(boolean_project_custom_field) do expect(page).to have_text "Boolean field" expect(page).to have_text I18n.t("placeholders.default") @@ -226,7 +226,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(string_project_custom_field) do expect(page).to have_text "String field" expect(page).to have_text "Foo" @@ -243,7 +243,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(string_project_custom_field) do expect(page).to have_text "String field" expect(page).to have_text I18n.t("placeholders.default") @@ -260,7 +260,7 @@ it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(string_project_custom_field) do expect(page).to have_text "String field" expect(page).to have_text I18n.t("placeholders.default") @@ -273,7 +273,7 @@ overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(string_project_custom_field) do expect(page).to have_text "String field" expect(page).to have_text I18n.t("placeholders.default") @@ -288,7 +288,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(integer_project_custom_field) do expect(page).to have_text "Integer field" expect(page).to have_text "123" @@ -305,7 +305,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(integer_project_custom_field) do expect(page).to have_text "Integer field" expect(page).to have_text I18n.t("placeholders.default") @@ -322,7 +322,7 @@ it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(integer_project_custom_field) do expect(page).to have_text "Integer field" expect(page).to have_text I18n.t("placeholders.default") @@ -335,7 +335,7 @@ overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(integer_project_custom_field) do expect(page).to have_text "Integer field" expect(page).to have_text I18n.t("placeholders.default") @@ -350,7 +350,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(date_project_custom_field) do expect(page).to have_text "Date field" expect(page).to have_text "01/01/2024" @@ -367,7 +367,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(date_project_custom_field) do expect(page).to have_text "Date field" expect(page).to have_text I18n.t("placeholders.default") @@ -384,7 +384,7 @@ it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(date_project_custom_field) do expect(page).to have_text "Date field" expect(page).to have_text I18n.t("placeholders.default") @@ -397,7 +397,7 @@ overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(date_project_custom_field) do expect(page).to have_text "Date field" expect(page).to have_text I18n.t("placeholders.default") @@ -412,7 +412,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(float_project_custom_field) do expect(page).to have_text "Float field" expect(page).to have_text "123.456" @@ -429,7 +429,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(float_project_custom_field) do expect(page).to have_text "Float field" expect(page).to have_text I18n.t("placeholders.default") @@ -446,7 +446,7 @@ it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(float_project_custom_field) do expect(page).to have_text "Float field" expect(page).to have_text I18n.t("placeholders.default") @@ -459,7 +459,7 @@ overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(float_project_custom_field) do expect(page).to have_text "Float field" expect(page).to have_text I18n.t("placeholders.default") @@ -479,7 +479,7 @@ it "shows the correct value for the project custom field if given without truncation and dialog button" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(text_project_custom_field) do expect(page).to have_text "Text field" expect(page).to have_text "Lorem ipsum" @@ -500,7 +500,7 @@ it "shows the correct value for the project custom field if given with truncation and dialog button" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(text_project_custom_field) do expect(page).to have_text "Text field" expect(page).to have_text (("lorem " * 5).to_s) @@ -523,7 +523,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(text_project_custom_field) do expect(page).to have_text "Text field" expect(page).to have_text I18n.t("placeholders.default") @@ -542,7 +542,7 @@ it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(text_project_custom_field) do expect(page).to have_text "Text field" expect(page).to have_text I18n.t("placeholders.default") @@ -557,7 +557,7 @@ overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(text_project_custom_field) do expect(page).to have_text "Text field" expect(page).to have_text I18n.t("placeholders.default") @@ -574,7 +574,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(list_project_custom_field) do expect(page).to have_text "List field" expect(page).to have_text "Option 1" @@ -591,7 +591,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(list_project_custom_field) do expect(page).to have_text "List field" expect(page).to have_text I18n.t("placeholders.default") @@ -608,7 +608,7 @@ it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(list_project_custom_field) do expect(page).to have_text "List field" expect(page).to have_text I18n.t("placeholders.default") @@ -621,7 +621,7 @@ overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(list_project_custom_field) do expect(page).to have_text "List field" expect(page).to have_text I18n.t("placeholders.default") @@ -636,7 +636,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(version_project_custom_field) do expect(page).to have_text "Version field" expect(page).to have_text "Version 1" @@ -653,7 +653,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(version_project_custom_field) do expect(page).to have_text "Version field" expect(page).to have_text I18n.t("placeholders.default") @@ -670,7 +670,7 @@ it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(version_project_custom_field) do expect(page).to have_text "Version field" expect(page).to have_text I18n.t("placeholders.default") @@ -685,7 +685,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(user_project_custom_field) do expect(page).to have_text "User field" expect(page).to have_css("opce-principal") @@ -703,7 +703,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(user_project_custom_field) do expect(page).to have_text "User field" expect(page).to have_text I18n.t("placeholders.default") @@ -720,7 +720,7 @@ it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(user_project_custom_field) do expect(page).to have_text "User field" expect(page).to have_text I18n.t("placeholders.default") @@ -735,7 +735,7 @@ it "shows the correct values for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(multi_list_project_custom_field) do expect(page).to have_text "Multi list field" expect(page).to have_text "Option 1, Option 2" @@ -752,7 +752,7 @@ it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(multi_list_project_custom_field) do expect(page).to have_text "Multi list field" expect(page).to have_text I18n.t("placeholders.default") @@ -766,7 +766,7 @@ overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(multi_list_project_custom_field) do expect(page).to have_text "Multi list field" expect(page).to have_text I18n.t("placeholders.default") @@ -781,7 +781,7 @@ it "shows the correct values for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(multi_version_project_custom_field) do expect(page).to have_text "Multi version field" expect(page).to have_text "Version 1, Version 2" @@ -798,7 +798,7 @@ it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(multi_version_project_custom_field) do expect(page).to have_text "Multi version field" expect(page).to have_text I18n.t("placeholders.default") @@ -813,7 +813,7 @@ it "shows the correct values for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(multi_user_project_custom_field) do expect(page).to have_text "Multi user field" expect(page).to have_css "opce-principal", count: 2 @@ -832,7 +832,7 @@ it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(multi_user_project_custom_field) do expect(page).to have_text "Multi user field" expect(page).to have_text I18n.t("placeholders.default") diff --git a/spec/lib/api/contracts/model_contract_spec.rb b/spec/lib/api/contracts/model_contract_spec.rb index dc47b35544b1..9854403d193a 100644 --- a/spec/lib/api/contracts/model_contract_spec.rb +++ b/spec/lib/api/contracts/model_contract_spec.rb @@ -149,8 +149,7 @@ context "when the model extends both modules" do before do - allow(model).to receive(:changed_by_user).and_return([:custom_field1]) - allow(model).to receive(:changed_with_custom_fields).and_return([:no_allowed]) + allow(model).to receive_messages(changed_by_user: [:custom_field1], changed_with_custom_fields: [:no_allowed]) end it "adds an error to the custom field attribute from the OpenProject::ChangedBySystem module" do @@ -159,5 +158,14 @@ .to include(:error_readonly) end end + + context "when a context is provided" do + it "propagates the contex to the model as well" do + allow(model).to receive(:valid?) + + model_contract.valid?(:custom_context) + expect(model).to have_received(:valid?).with(:custom_context) + end + end end end diff --git a/spec/migrations/add_project_life_cycle_step_roles_spec.rb b/spec/migrations/add_project_life_cycle_step_roles_spec.rb new file mode 100644 index 000000000000..d7317dd8ac1e --- /dev/null +++ b/spec/migrations/add_project_life_cycle_step_roles_spec.rb @@ -0,0 +1,135 @@ +#-- 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. +#++ + +require "spec_helper" +require Rails.root.join("db/migrate/20241126111225_add_project_life_cycle_step_roles.rb") + +RSpec.describe AddProjectLifeCycleStepRoles, type: :model do + # Silencing migration logs, since we are not interested in that during testing + subject { ActiveRecord::Migration.suppress_messages { described_class.new.change } } + + shared_examples_for "not changing permissions" do + it "is not changed" do + expect { subject }.not_to change { role.reload.permissions } + end + + it "does not adds any new permissions" do + expect { subject }.not_to change(RolePermission, :count) + end + end + + shared_examples_for "migration is idempotent" do + context "when the migration is ran twice" do + before { subject } + + it_behaves_like "not changing permissions" + end + end + + shared_examples_for "adding permissions" do |new_permissions| + it "adds the #{new_permissions} permissions for the role" do + public_permissions = OpenProject::AccessControl.public_permissions.map(&:name) + expect { subject }.to change { role.reload.permissions } + .from(match_array(permissions + public_permissions)) + .to match_array(permissions + public_permissions + new_permissions) + end + + it "adds #{new_permissions.size} new permissions" do + expect { subject }.to change(RolePermission, :count).by(new_permissions.size) + end + end + + context "for a role not eligible to view_project_stages_and_gates" do + let!(:role) do + create(:project_role, + add_public_permissions: false, + permissions: %i[permission1 permission2]) + end + + it_behaves_like "not changing permissions" + it_behaves_like "migration is idempotent" + end + + context "for a role eligible to view_project_stages_and_gates" do + let(:permissions) { %i[view_project permission1 permission2] } + let!(:role) { create(:project_role, permissions:) } + + it_behaves_like "adding permissions", %i[view_project_stages_and_gates] + it_behaves_like "migration is idempotent" + end + + context "for a role with view_project_stages_and_gates" do + let(:permissions) { %i[view_project_stages_and_gates view_project permission1 permission2] } + let!(:role) { create(:project_role, permissions:) } + + it_behaves_like "not changing permissions" + it_behaves_like "migration is idempotent" + end + + context "for a role not eligible to edit_project_stages_and_gates" do + let(:permissions) do + %i[view_project_stages_and_gates permission1 permission2] + end + let!(:role) { create(:project_role, permissions:) } + + it_behaves_like "not changing permissions" + it_behaves_like "migration is idempotent" + end + + context "for a role eligible to edit_project_stages_and_gates having view_project_stages_and_gates" do + let(:permissions) do + %i[view_project_stages_and_gates edit_project + view_project permission1 permission2] + end + let!(:role) { create(:project_role, permissions:) } + + it_behaves_like "adding permissions", %i[edit_project_stages_and_gates] + it_behaves_like "migration is idempotent" + end + + context "for a role eligible to edit_project_stages_and_gates not having view_project_stages_and_gates" do + let(:permissions) do + %i[edit_project view_project permission1 permission2] + end + let!(:role) { create(:project_role, permissions:) } + + it_behaves_like "adding permissions", %i[edit_project_stages_and_gates view_project_stages_and_gates] + it_behaves_like "migration is idempotent" + end + + context "for a role that already has the edit_project_stages_and_gates and view_project_stages_and_gates permission" do + let(:permissions) do + %i[edit_project_stages_and_gates view_project_stages_and_gates + edit_project view_project permission1 permission2] + end + let!(:role) { create(:project_role, permissions:) } + + it_behaves_like "not changing permissions" + it_behaves_like "migration is idempotent" + end +end diff --git a/spec/models/project/gate_definition_spec.rb b/spec/models/project/gate_definition_spec.rb index f943a416d9b9..2b1d4c2f9eca 100644 --- a/spec/models/project/gate_definition_spec.rb +++ b/spec/models/project/gate_definition_spec.rb @@ -41,6 +41,14 @@ end end + describe "validations" do + it "is invalid if type and class name do not match" do + subject.type = "Project::StageDefinition" + expect(subject).not_to be_valid + expect(subject.errors.symbols_for(:type)).to include(:type_and_class_name_mismatch) + end + end + describe "#step_class" do it "returns Project::Stage" do expect(subject.step_class).to eq(Project::Gate) diff --git a/spec/models/project/gate_spec.rb b/spec/models/project/gate_spec.rb index f634c63c1836..5fb5bcc7825b 100644 --- a/spec/models/project/gate_spec.rb +++ b/spec/models/project/gate_spec.rb @@ -33,7 +33,6 @@ it_behaves_like "a Project::LifeCycleStep event" describe "validations" do - it { is_expected.to validate_presence_of(:date) } it { is_expected.to validate_inclusion_of(:type).in_array(["Project::Gate"]).with_message(:must_be_a_gate) } it "is invalid if `end_date` is present" do @@ -41,12 +40,18 @@ expect(subject).not_to be_valid expect(subject.errors[:base]) - .to include("Cannot assign `end_date` to a Project::Gate") + .to include("Cannot assign end date to a Project::Gate") end it "is valid if `end_date` is not present" do valid_gate = build(:project_gate, end_date: nil) expect(valid_gate).to be_valid end + + it "is invalid if type and class name do not match" do + subject.type = "Project::Stage" + expect(subject).not_to be_valid + expect(subject.errors.symbols_for(:type)).to include(:type_and_class_name_mismatch) + end end end diff --git a/spec/models/project/life_cycle_step_definition_spec.rb b/spec/models/project/life_cycle_step_definition_spec.rb index 6251817bb2ef..f5c69c5f7f93 100644 --- a/spec/models/project/life_cycle_step_definition_spec.rb +++ b/spec/models/project/life_cycle_step_definition_spec.rb @@ -29,8 +29,8 @@ require "rails_helper" RSpec.describe Project::LifeCycleStepDefinition do - it "cannot be instantiated" do - expect { described_class.new }.to raise_error(NotImplementedError) + it "can be instantiated" do + expect { described_class.new }.not_to raise_error end context "with a Project::StageDefinition" do @@ -39,6 +39,14 @@ it { is_expected.to have_readonly_attribute(:type) } end + describe "validations" do + it "is invalid if type and class name do not match" do + subject.type = "Project::GateDefinition" + expect(subject).not_to be_valid + expect(subject.errors.symbols_for(:type)).to include(:type_and_class_name_mismatch) + end + end + # For more specs see: # - spec/support/shared/project_life_cycle_helpers.rb # - spec/models/project/gate_definition_spec.rb diff --git a/spec/models/project/life_cycle_step_spec.rb b/spec/models/project/life_cycle_step_spec.rb index 03722a2cb66a..e1e882d5e1d9 100644 --- a/spec/models/project/life_cycle_step_spec.rb +++ b/spec/models/project/life_cycle_step_spec.rb @@ -29,8 +29,8 @@ require "rails_helper" RSpec.describe Project::LifeCycleStep do - it "cannot be instantiated" do - expect { described_class.new }.to raise_error(NotImplementedError) + it "can be instantiated" do + expect { described_class.new }.not_to raise_error(NotImplementedError) end describe "with an instantiated Gate" do @@ -40,6 +40,14 @@ it { is_expected.to have_readonly_attribute(:type) } end + describe "validations" do + it "is invalid if type and class name do not match" do + subject.type = "Project::Gate" + expect(subject).not_to be_valid + expect(subject.errors.symbols_for(:type)).to include(:type_and_class_name_mismatch) + end + end + # For more specs see: # - spec/support/shared/project_life_cycle_helpers.rb # - spec/models/project/gate_spec.rb diff --git a/spec/models/project/stage_definition_spec.rb b/spec/models/project/stage_definition_spec.rb index 1d94467a546a..0f236e6b46a6 100644 --- a/spec/models/project/stage_definition_spec.rb +++ b/spec/models/project/stage_definition_spec.rb @@ -41,6 +41,14 @@ end end + describe "validations" do + it "is invalid if type and class name do not match" do + subject.type = "Project::GateDefinition" + expect(subject).not_to be_valid + expect(subject.errors.symbols_for(:type)).to include(:type_and_class_name_mismatch) + end + end + describe "#step_class" do it "returns Project::Stage" do expect(subject.step_class).to eq(Project::Stage) diff --git a/spec/models/project/stage_spec.rb b/spec/models/project/stage_spec.rb index 6fd727a34b27..99388f780f89 100644 --- a/spec/models/project/stage_spec.rb +++ b/spec/models/project/stage_spec.rb @@ -33,8 +33,6 @@ it_behaves_like "a Project::LifeCycleStep event" describe "validations" do - it { is_expected.to validate_presence_of(:start_date) } - it { is_expected.to validate_presence_of(:end_date) } it { is_expected.to validate_inclusion_of(:type).in_array(["Project::Stage"]).with_message(:must_be_a_stage) } it "is valid when `start_date` and `end_date` are present" do @@ -42,4 +40,108 @@ expect(valid_stage).to be_valid end end + + describe "#not_set?" do + it "returns true if start_date or end_date is blank" do + expect(subject.not_set?).to be(true) + end + + it "returns false if both start_date and end_date are present" do + subject.start_date = Time.zone.today + subject.end_date = Date.tomorrow + expect(subject.not_set?).to be(false) + end + end + + describe "#date_range=" do + it "splits a valid date range string into start_date and end_date" do + subject.date_range = "2024-11-26 - 2024-11-27" + expect(subject.start_date).to eq(Date.parse("2024-11-26")) + expect(subject.end_date).to eq(Date.parse("2024-11-27")) + end + + it "sets end_date to start_date if a single date is provided" do + subject.date_range = "2024-11-26" + expect(subject.start_date).to eq(Date.parse("2024-11-26")) + expect(subject.end_date).to eq(Date.parse("2024-11-26")) + end + end + + describe "#validate_date_range" do + it "is valid when both dates are blank" do + stage = build(:project_stage, start_date: nil, end_date: nil) + expect(stage).to be_valid + end + + it "adds error if start_date is blank" do + subject.end_date = Time.zone.today + expect(subject).not_to be_valid + expect(subject.errors.symbols_for(:date_range)).to include(:incomplete) + end + + it "adds error if end_date is blank" do + subject.start_date = Time.zone.today + expect(subject).not_to be_valid + expect(subject.errors.symbols_for(:date_range)).to include(:incomplete) + end + + it "adds error if start_date is after end_date" do + subject.start_date = Date.tomorrow + subject.end_date = Time.zone.today + expect(subject).not_to be_valid + expect(subject.errors.symbols_for(:date_range)).to include(:start_date_must_be_before_end_date) + end + + it "does not add errors if start_date is before or equal to end_date" do + subject.start_date = Time.zone.today + subject.end_date = Time.zone.today + expect(subject).not_to be_valid + expect(subject.errors[:date_range]).to be_empty + end + + it "is invalid if type and class name do not match" do + subject.type = "Project::Gate" + expect(subject).not_to be_valid + expect(subject.errors.symbols_for(:type)).to include(:type_and_class_name_mismatch) + end + end + + describe "#working_days_count" do + it "returns nil if not_set? is true" do + allow(Day).to receive(:working) + + subject.start_date = nil + subject.end_date = nil + + expect(subject.working_days_count).to be_nil + expect(Day).not_to have_received(:working) + end + + it "returns the correct number of days if start_date and end_date are the same" do + subject.start_date = Time.zone.today + subject.end_date = Time.zone.today + expect(subject.working_days_count).to eq(1) + end + + it "returns the correct number of days for a valid date range" do + subject.start_date = Date.parse("2024-11-25") + subject.end_date = Date.parse("2024-11-27") + expect(subject.working_days_count).to eq(3) + end + + it "calls the Day.working.from_range method with the right arguments" do + subject.start_date = Date.parse("2024-11-25") + subject.end_date = Date.parse("2024-11-27") + + allow(Day).to receive(:working).and_return(Day) + allow(Day).to receive(:from_range) + .with(from: subject.start_date, to: subject.end_date) + .and_return([]) + + expect(subject.working_days_count).to eq(0) + + expect(Day).to have_received(:working).with(no_args) + expect(Day).to have_received(:from_range).with(from: subject.start_date, to: subject.end_date) + end + end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 2f191a053f34..ab2e73be473a 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -383,6 +383,32 @@ describe "life_cycles" do it { is_expected.to have_many(:life_cycle_steps).class_name("Project::LifeCycleStep").dependent(:destroy) } + + it "has many available_life_cycle_steps" do + expect(subject).to have_many(:available_life_cycle_steps) + .class_name("Project::LifeCycleStep") + .inverse_of(:project) + .dependent(:destroy) + .conditions(active: true) + .order(position: :asc) + end + + it "eager loads :definition" do + expect(subject.available_life_cycle_steps.to_sql) + .to include("LEFT OUTER JOIN \"project_life_cycle_step_definitions\" ON") + end + + describe ".validates_associated" do + let!(:project_stage) { create :project_stage, :skip_validate, project:, start_date: nil } + + it "is valid without a validation context" do + expect(project).to be_valid + end + + it "is invalid with the :saving_life_cycle_steps validation context" do + expect(project).not_to be_valid(:saving_life_cycle_steps) + end + end end describe "#enabled_module_names=", with_settings: { default_projects_modules: %w(work_package_tracking repository) } do diff --git a/spec/permissions/edit_project_life_cycles_spec.rb b/spec/permissions/edit_project_life_cycles_spec.rb new file mode 100644 index 000000000000..7e81ea0f34d1 --- /dev/null +++ b/spec/permissions/edit_project_life_cycles_spec.rb @@ -0,0 +1,44 @@ +#-- 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. +#++ + +require "spec_helper" +require File.expand_path("../support/permission_specs", __dir__) + +RSpec.describe Overviews::OverviewsController, "edit_project_life_cycles permission", # rubocop:disable RSpec/EmptyExampleGroup,RSpec/SpecFilePathFormat + type: :controller do + include PermissionSpecs + + # render dialog with inputs for editing project attributes with edit_project permission + check_permission_required_for("overviews/overviews#project_life_cycles_dialog", :edit_project_stages_and_gates) + + # render form with inputs for editing project attributes with edit_project permission + check_permission_required_for("overviews/overviews#project_life_cycles_form", :edit_project_stages_and_gates) + + # update project attributes with edit_project permission, deeper permission check via contract in place + check_permission_required_for("overviews/overviews#update_project_life_cycles", :edit_project_stages_and_gates) +end diff --git a/spec/permissions/view_project_life_cycles_spec.rb b/spec/permissions/view_project_life_cycles_spec.rb new file mode 100644 index 000000000000..294c55fa8a68 --- /dev/null +++ b/spec/permissions/view_project_life_cycles_spec.rb @@ -0,0 +1,38 @@ +#-- 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. +#++ + +require "spec_helper" +require File.expand_path("../support/permission_specs", __dir__) + +RSpec.describe Overviews::OverviewsController, "view_project_life_cycles permission", # rubocop:disable RSpec/EmptyExampleGroup,RSpec/SpecFilePathFormat + type: :controller do + include PermissionSpecs + + # render sidebar on project overview page with view_project permission + check_permission_required_for("overviews/overviews#project_life_cycles_sidebar", :view_project_stages_and_gates) +end diff --git a/spec/support/components/datepicker/basic_datepicker.rb b/spec/support/components/datepicker/basic_datepicker.rb index 08198fccd8b1..ade084d66983 100644 --- a/spec/support/components/datepicker/basic_datepicker.rb +++ b/spec/support/components/datepicker/basic_datepicker.rb @@ -22,5 +22,10 @@ def self.update_field(trigger, date) def flatpickr_container container.find(".flatpickr-calendar") end + + def open(trigger) + input = page.find(trigger) + input.click + end end end diff --git a/spec/support/components/projects/project_life_cycles/edit_dialog.rb b/spec/support/components/projects/project_life_cycles/edit_dialog.rb new file mode 100644 index 000000000000..83c1a3fce9f8 --- /dev/null +++ b/spec/support/components/projects/project_life_cycles/edit_dialog.rb @@ -0,0 +1,158 @@ +# -- 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. +# ++ + +require "support/components/common/modal" +require "support/components/autocompleter/ng_select_autocomplete_helpers" +module Components + module Projects + module ProjectLifeCycles + class EditDialog < Components::Common::Modal + def dialog_css_selector + "dialog#edit-project-life-cycles-dialog" + end + + def async_content_container_css_selector + "#{dialog_css_selector} [data-test-selector='async-dialog-content']" + end + + def within_dialog(&) + within(dialog_css_selector, &) + end + + def within_async_content(close_after_yield: false, &) + within(async_content_container_css_selector, &) + close if close_after_yield + end + + def set_date_for(step, value:) + datepicker = if value.is_a?(Array) + Components::RangeDatepicker.new + else + Components::BasicDatepicker.new + end + + datepicker.open( + "input[id^='project_available_life_cycle_steps_attributes_#{step.position - 1}']" + ) + + Array(value).each do |date| + datepicker.set_date(date.strftime("%Y-%m-%d")) + end + end + + def close + within_dialog do + page.find(".close-button").click + end + end + alias_method :close_via_icon, :close + + def close_via_button + within(dialog_css_selector) do + click_link_or_button "Cancel" + end + end + + def submit + within(dialog_css_selector) do + page.find("[data-test-selector='save-project-life-cycles-button']").click + end + end + + def expect_open + expect(page).to have_css(dialog_css_selector) + end + + def expect_closed + expect(page).to have_no_css(dialog_css_selector) + end + + def expect_async_content_loaded + expect(page).to have_css(async_content_container_css_selector) + end + + def expect_input(label, value:, type:, position:) + field = type == :stage ? :date_range : :date + within_async_content do + expect(page).to have_field( + label, + with: value, + name: "project[available_life_cycle_steps_attributes][#{position - 1}][#{field}]" + ) + end + end + + def expect_input_for(step) + options = if step.is_a?(Project::Stage) + value = "#{step.start_date.strftime('%Y-%m-%d')} - #{step.end_date.strftime('%Y-%m-%d')}" + { type: :stage, value: } + else + value = step.date.strftime("%Y-%m-%d") + { type: :gate, value: } + end + + expect_input(step.name, position: step.position, **options) + end + + def expect_caption(step, text: nil, present: true) + selector = 'span[id^="caption"]' + expect_selector_for(step, selector:, text:, present:) + end + + def expect_no_caption(step) + expect_caption(step, present: false) + end + + def expect_validation_message(step, text: nil, present: true) + selector = 'div[id^="validation"]' + expect_selector_for(step, selector:, text:, present:) + end + + def expect_no_validation_message(step) + expect_validation_message(step, present: false) + end + + private + + def expect_selector_for(step, selector:, text: nil, present: true) + within_async_content do + field = step.is_a?(Project::Stage) ? :date_range : :date + input_id = "#project_available_life_cycle_steps_attributes_#{step.position - 1}_#{field}" + parent = find(input_id).ancestor("primer-datepicker-field") + + if present + expect(parent).to have_selector(selector, text:) + else + expect(parent).to have_no_selector(selector) + end + end + end + end + end + end +end diff --git a/spec/support/pages/projects/show.rb b/spec/support/pages/projects/show.rb index 7e1ea638a052..4877c69ab00e 100644 --- a/spec/support/pages/projects/show.rb +++ b/spec/support/pages/projects/show.rb @@ -33,11 +33,9 @@ module Projects class Show < ::Pages::Page attr_reader :project - # rubocop:disable Lint/MissingSuper def initialize(project) @project = project end - # rubocop:enable Lint/MissingSuper def path project_path(project) @@ -53,10 +51,15 @@ def visit_page def expect_no_visible_sidebar expect_angular_frontend_initialized - expect(page).to have_no_css(".op-grid-page--grid-container") + expect(page).to have_no_css(".op-grid-page--sidebar") end - def within_async_loaded_sidebar(&) + def expect_visible_sidebar + expect_angular_frontend_initialized + expect(page).to have_css(".op-grid-page--sidebar") + end + + def within_project_attributes_sidebar(&) within "#project-custom-fields-sidebar" do expect(page).to have_css("[data-test-selector='project-custom-fields-sidebar-async-content']") yield @@ -72,7 +75,7 @@ def within_custom_field_container(custom_field, &) end def open_edit_dialog_for_section(section) - within_async_loaded_sidebar do + within_project_attributes_sidebar do scroll_to_element(page.find("[data-test-selector='project-custom-field-section-#{section.id}']")) within_custom_field_section_container(section) do page.find("[data-test-selector='project-custom-field-section-edit-button']").click @@ -82,6 +85,25 @@ def open_edit_dialog_for_section(section) expect(page).to have_css("[data-test-selector='async-dialog-content']", wait: 5) end + def open_edit_dialog_for_life_cycles + within_life_cycles_sidebar do + page.find("[data-test-selector='project-life-cycles-edit-button']").click + end + + Components::Projects::ProjectLifeCycles::EditDialog.new.tap(&:expect_open) + end + + def within_life_cycles_sidebar(&) + within "#project-life-cycles-sidebar" do + expect(page).to have_css("[data-test-selector='project-life-cycles-sidebar-async-content']") + yield + end + end + + def within_life_cycle_container(life_cycle, &) + within("[data-test-selector='project-life-cycle-#{life_cycle.id}']", &) + end + def expand_text(custom_field) within_custom_field_container(custom_field) do page.find('[data-test-selector="expand-button"]').click diff --git a/spec/support/rspec_retry.rb b/spec/support/rspec_retry.rb index 281fe9779792..8145d377aac8 100644 --- a/spec/support/rspec_retry.rb +++ b/spec/support/rspec_retry.rb @@ -76,6 +76,11 @@ def retry_block(args: {}, screenshot: false, &) end end + # By default retry_block works with StandardError, but the ExpectationNotMetError is + # not inherited from StandardError. Adding the RSpec::Expectations::ExpectationNotMetError + # will makes sure we retry if an expectation fails inside the retry_block. + args[:on] ||= [StandardError, RSpec::Expectations::ExpectationNotMetError] + Retriable.retriable(on_retry: log_errors, **args, &) end