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 @@