Skip to content

Commit

Permalink
Merge pull request #17223 from opf/implementation/59288-add-stages-an…
Browse files Browse the repository at this point in the history
…d-gates-to-the-project-overview-page

Implementation/59288 add stages and gates to the project overview page
  • Loading branch information
dombesz authored Dec 18, 2024
2 parents 1d73cab + f0fce0e commit d4fe6d8
Show file tree
Hide file tree
Showing 76 changed files with 2,835 additions and 127 deletions.
9 changes: 8 additions & 1 deletion app/components/concerns/op_turbo/streamable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions app/contracts/model_contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
79 changes: 79 additions & 0 deletions app/contracts/project_life_cycle_steps/base_contract.rb
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions app/contracts/project_life_cycle_steps/update_contract.rb
Original file line number Diff line number Diff line change
@@ -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
13 changes: 7 additions & 6 deletions app/controllers/concerns/op_turbo/component_stream.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions app/forms/application_form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 104 additions & 0 deletions app/forms/projects/life_cycles/form.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions app/models/permitted_params.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
8 changes: 8 additions & 0 deletions app/models/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion app/models/project/gate.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,15 @@ 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
if end_date.present?
errors.add(:base, :end_date_not_allowed)
end
end

def not_set?
date.blank?
end
end
17 changes: 9 additions & 8 deletions app/models/project/life_cycle_step.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit d4fe6d8

Please sign in to comment.