diff --git a/app/components/op_primer/status_button_component.rb b/app/components/op_primer/status_button_component.rb
index 5e4b931a6d2f..842f9c881307 100644
--- a/app/components/op_primer/status_button_component.rb
+++ b/app/components/op_primer/status_button_component.rb
@@ -65,10 +65,12 @@ def readonly?
end
def button_content(button)
- button.with_leading_visual_icon(icon: @current_status.icon) if @current_status.icon
- button.with_trailing_action_icon(icon: "triangle-down") if !readonly? && @items.any?
+ if !@button_arguments[:icon_only]
+ button.with_leading_visual_icon(icon: @current_status.icon) if @current_status.icon
+ button.with_trailing_action_icon(icon: "triangle-down") if !readonly? && @items.any?
- @current_status.name
+ @current_status.name
+ end
end
def button_arguments
@@ -82,7 +84,7 @@ def button_arguments
aria: {
label: title
}
- }.compact.deep_merge(@button_arguments)
+ }.compact.deep_merge(@button_arguments.except(:classes))
end
def highlight_class_name(status, style)
diff --git a/app/components/projects/phase_status_button_component.html.erb b/app/components/projects/phase_status_button_component.html.erb
new file mode 100644
index 000000000000..8a89ba35ce1e
--- /dev/null
+++ b/app/components/projects/phase_status_button_component.html.erb
@@ -0,0 +1,29 @@
+<%=
+ component_wrapper do
+ flex_layout do |container|
+ container.with_row do
+ render(
+ OpPrimer::StatusButtonComponent.new(
+ current_status: build_item(@status),
+ items: build_items,
+ button_arguments: {
+ icon_only: true,
+ classes: class_names(
+ "op-phase-status-button"
+ ),
+ title: project_phase_status_name(@status.code),
+ size: :medium,
+ scheme: :invisible,
+ style: :foreground,
+ icon: @status.icon,
+ "aria-label": project_phase_status_name(@status.code)
+ },
+ menu_arguments: {
+ data: { test_selector: "phase-status-action-menu-#{@phase.id}" }
+ }
+ )
+ )
+ end
+ end
+ end
+%>
diff --git a/app/components/projects/phase_status_button_component.rb b/app/components/projects/phase_status_button_component.rb
new file mode 100644
index 000000000000..f6ee85a3544a
--- /dev/null
+++ b/app/components/projects/phase_status_button_component.rb
@@ -0,0 +1,93 @@
+# 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, see .
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+class Projects::PhaseStatusButtonComponent < ApplicationComponent
+ include ApplicationHelper
+ include OpTurbo::Streamable
+ include OpPrimer::ComponentHelpers
+ include Primer::ClassNameHelper
+
+ attr_reader :phase, :user
+
+ def initialize(phase:, user:, **system_arguments)
+ super
+
+ @phase = phase
+ @user = user
+ @status = Projects::PhaseStatuses.find(phase.status)
+ @system_arguments = system_arguments
+ end
+
+ def wrapper_uniq_by
+ phase
+ end
+
+ def render? = @status.present? && edit_enabled?
+
+ private
+
+ def edit_enabled?
+ user.allowed_in_project?(:edit_project_phases, phase.project)
+ end
+
+ def build_items
+ Projects::PhaseStatuses::AVAILABLE.map { build_item(it) }
+ end
+
+ def build_item(status)
+ OpPrimer::StatusButtonOption.new(
+ name: project_phase_status_name(status.code),
+ description: project_phase_status_description(status.code),
+ color_namespace: :project_phase_status,
+ color_ref: status.id,
+ icon: status.icon,
+ item_id: status.id,
+ tag: :a,
+ href: project_phase_status_path(phase, status: status.code),
+ content_arguments: {
+ data: { turbo_method: status.value ? :put : :delete },
+ aria: { current: (true if status == @status) }
+ }
+ )
+ end
+
+ ##
+ # @param status_code [String | Symbol | nil] Project Phase Status code
+ # @return [String] the localized name.
+ def project_phase_status_name(status_code)
+ I18n.t(status_code, scope: "projects.phase_status.title")
+ end
+
+ ##
+ # @param status_code [String | Symbol | nil] Project Phase Status code
+ # @return [String] the localized description.
+ def project_phase_status_description(status_code)
+ I18n.t(status_code, scope: "projects.phase_status.description")
+ end
+end
diff --git a/app/constants/projects/phase_statuses.rb b/app/constants/projects/phase_statuses.rb
new file mode 100644
index 000000000000..669ed81bd690
--- /dev/null
+++ b/app/constants/projects/phase_statuses.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, see .
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+module Projects
+ module PhaseStatuses
+ Status = Data.define(:code, :hexcode, :icon) do
+ def id = code&.to_s || :none
+ def value = code&.to_s
+
+ def color = Color.new(hexcode:)
+ end
+
+ OPEN = Status.new(code: :open, hexcode: "#006edb", icon: "issue-opened")
+ IN_PROGRESS = Status.new(code: :in_progress, hexcode: "#1F883D", icon: "play")
+ COMPLETED = Status.new(code: :completed, hexcode: "#25292e", icon: "check-circle")
+
+ AVAILABLE = [OPEN, IN_PROGRESS, COMPLETED].freeze
+
+ def self.find(code)
+ AVAILABLE.find { it.code.to_s == code.to_s }
+ end
+ end
+end
diff --git a/app/contracts/project_phases/base_contract.rb b/app/contracts/project_phases/base_contract.rb
index 83951868d94c..a7d1c881f76b 100644
--- a/app/contracts/project_phases/base_contract.rb
+++ b/app/contracts/project_phases/base_contract.rb
@@ -29,6 +29,8 @@
module ProjectPhases
class BaseContract < ::ModelContract
+ delegate :project, to: :model
+
validate :validate_edit_project_phases_permission
def validate_edit_project_phases_permission
diff --git a/app/contracts/project_phases/update_contract.rb b/app/contracts/project_phases/update_contract.rb
index d5163792ae18..54057d1ea9e9 100644
--- a/app/contracts/project_phases/update_contract.rb
+++ b/app/contracts/project_phases/update_contract.rb
@@ -34,8 +34,6 @@ class UpdateContract < BaseContract
validate :validate_finish_date_is_a_working_day
validate :validate_date_format
- delegate :project, to: :model
-
def writable_attributes = %w[start_date finish_date]
def validate_start_after_preceeding_phases
diff --git a/app/contracts/project_phases/update_status_contract.rb b/app/contracts/project_phases/update_status_contract.rb
new file mode 100644
index 000000000000..f8a03fa399bf
--- /dev/null
+++ b/app/contracts/project_phases/update_status_contract.rb
@@ -0,0 +1,34 @@
+# 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, see .
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+module ProjectPhases
+ class UpdateStatusContract < BaseContract
+ attribute :status
+ end
+end
diff --git a/app/controllers/project_phases/status_controller.rb b/app/controllers/project_phases/status_controller.rb
new file mode 100644
index 000000000000..41829cfcdfd8
--- /dev/null
+++ b/app/controllers/project_phases/status_controller.rb
@@ -0,0 +1,68 @@
+# 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, see .
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+module ProjectPhases
+ class StatusController < ApplicationController
+ include OpTurbo::ComponentStream
+ include OpTurbo::FlashStreamHelper
+
+ before_action :find_phase
+ before_action :authorize
+
+ def update
+ call = ProjectPhases::UpdateStatusService
+ .new(user: current_user, model: @phase)
+ .call(status: params.require(:status))
+
+ if call.success?
+ refresh_buttons
+ render_success_flash_message_via_turbo_stream(message: t(:notice_successful_update))
+ respond_with_turbo_streams
+ else
+ message = t(:notice_unsuccessful_update_with_reason, reason: call.message)
+ respond_with_flash_error(message:)
+ end
+ end
+
+ private
+
+ def find_phase
+ @phase = Project::Phase.where(active: true).find(params.expect(:project_phase_id))
+ @project = @phase.project
+ end
+
+ def refresh_buttons
+ @project.available_phases.reload.each do |phase|
+ update_via_turbo_stream(
+ component: Projects::PhaseStatusButtonComponent.new(phase:, user: current_user)
+ )
+ end
+ end
+ end
+end
diff --git a/app/models/project/phase.rb b/app/models/project/phase.rb
index 7caed4ee7ccc..40a56abdc8c8 100644
--- a/app/models/project/phase.rb
+++ b/app/models/project/phase.rb
@@ -49,6 +49,8 @@ class Project::Phase < ApplicationRecord
attr_readonly :definition_id
+ enum :status, { open: "open", in_progress: "in_progress", completed: "completed" }, validate: true
+
scope :active, -> { where(active: true) }
scope :with_timeline_content, -> {
joins(:definition).where(
diff --git a/app/services/project_phases/update_status_service.rb b/app/services/project_phases/update_status_service.rb
new file mode 100644
index 000000000000..0e27ba040a59
--- /dev/null
+++ b/app/services/project_phases/update_status_service.rb
@@ -0,0 +1,75 @@
+# 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, see .
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+module ProjectPhases
+ class UpdateStatusService < ::BaseServices::Update
+ delegate :project, to: :model
+
+ def after_perform(*)
+ cascade_to_other_phases
+ project.touch_and_save_journals
+
+ super
+ end
+
+ private
+
+ def cascade_to_other_phases
+ earlier, later = project.available_phases
+ .reject { it == model }
+ .partition { it.position < model.position }
+
+ earlier_status, later_status = cascaded_statuses.values_at(:earlier, :later)
+
+ update_statuses(earlier, earlier_status)
+ update_statuses(later, later_status)
+ end
+
+ def update_statuses(phases, status)
+ return if status.nil?
+
+ ids = phases.reject { it.status == status }.map(&:id)
+ return if ids.empty?
+
+ Project::Phase.where(id: ids).update_all(status:, updated_at: Time.current)
+ end
+
+ def cascaded_statuses
+ open, in_progress, completed = Project::Phase.statuses.values_at(:open, :in_progress, :completed)
+
+ case model.status
+ when open then { later: open }
+ when in_progress then { earlier: completed, later: open }
+ when completed then { earlier: completed }
+ end
+ end
+
+ def default_contract_class = ProjectPhases::UpdateStatusContract
+ end
+end
diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb
index 31bf45be6dc1..1ce98079444e 100644
--- a/config/initializers/permissions.rb
+++ b/config/initializers/permissions.rb
@@ -191,7 +191,7 @@
dependencies: :view_project
map.permission :edit_project_phases,
- {},
+ { "project_phases/status": :update },
permissible_on: :project,
require: :member,
dependencies: :view_project_phases,
diff --git a/config/initializers/resource_colors.rb b/config/initializers/resource_colors.rb
index cd9bf9726b5a..1693fbc02b2e 100644
--- a/config/initializers/resource_colors.rb
+++ b/config/initializers/resource_colors.rb
@@ -35,4 +35,5 @@
Highlighting::Registry.register_model key: "color", model: Color
Highlighting::Registry.register_static key: "project_status", values: Projects::Statuses::AVAILABLE
+ Highlighting::Registry.register_static key: "project_phase_status", values: Projects::PhaseStatuses::AVAILABLE
end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 08a11a28319f..a93dc11969cb 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -5959,6 +5959,15 @@ en:
new:
placeholder: "New project list"
shared: "Shared project lists"
+ phase_status:
+ description:
+ completed: 'Marking a phase as complete will complete all previous phases.'
+ in_progress: 'Marking a phase as complete will complete all previous phases.'
+ open: 'Re-opening a phase will also open all future phases.'
+ title:
+ completed: 'Completed'
+ in_progress: 'In progress'
+ open: 'Open'
settings:
actions:
label_disable_all: "Disable all"
diff --git a/config/routes.rb b/config/routes.rb
index d9b3223472f6..433f9383f84d 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -792,6 +792,7 @@
member do
get "/hover_card" => "project_phases/hover_card#show", as: "hover_card"
end
+ resource :status, only: :update, controller: "project_phases/status"
end
resources :admin, controller: :admin, only: :index do
diff --git a/db/migrate/20261019124852_add_status_to_project_phases.rb b/db/migrate/20261019124852_add_status_to_project_phases.rb
new file mode 100644
index 000000000000..4c23f7fda57d
--- /dev/null
+++ b/db/migrate/20261019124852_add_status_to_project_phases.rb
@@ -0,0 +1,40 @@
+# 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, see .
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+class AddStatusToProjectPhases < ActiveRecord::Migration[8.1]
+ def change
+ add_column :project_phases, :status, :string, null: false, default: "open"
+ add_column :project_phase_journals, :status, :string, null: false, default: "open"
+
+ add_index :project_phases, :status
+
+ add_check_constraint :project_phases, "status IN ('open', 'in_progress', 'completed')",
+ name: "project_phases_status_valid"
+ end
+end
diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass
index 3a11cce6c2c4..a9f743103e22 100644
--- a/frontend/src/global_styles/primer/_overrides.sass
+++ b/frontend/src/global_styles/primer/_overrides.sass
@@ -104,6 +104,13 @@ ul.SegmentedControl,
svg
align-self: center
+// The StatusButtonComponent overrides the icon color when it’s an IconButton.
+// This makes sure the icon takes the color we set when we use it in the
+// PhaseStatusButton
+.op-phase-status-button
+ svg.Button-visual
+ color: inherit
+
// Prototypical CKEditor adjustments to better match Primer styles
.ck-editor-primer-adjusted
.ck-toolbar
diff --git a/lookbook/previews/op_primer/status_button_component_preview.rb b/lookbook/previews/op_primer/status_button_component_preview.rb
index e64033657a7f..69b8635de337 100644
--- a/lookbook/previews/op_primer/status_button_component_preview.rb
+++ b/lookbook/previews/op_primer/status_button_component_preview.rb
@@ -110,5 +110,37 @@ def with_description(size: :medium)
render(component)
end
+
+ # See the [component documentation](/lookbook/pages/components/status_button) for more details.
+ # @display min_height 100px
+ def icon_only(size: :medium)
+ status = OpPrimer::StatusButtonOption.new(name: "Open",
+ color_ref: :open,
+ color_namespace: :meeting_status,
+ icon: :unlock)
+
+ items = [
+ status,
+ OpPrimer::StatusButtonOption.new(name: "Closed",
+ color_ref: :closed,
+ color_namespace: :meeting_status,
+ icon: :lock)
+ ]
+
+ component = OpPrimer::StatusButtonComponent.new(current_status: status,
+ items: items,
+ readonly: false,
+ button_arguments: {
+ size:,
+ title: "foo",
+ icon_only: true,
+ scheme: :invisible,
+ style: :foreground,
+ icon: :"check-circle",
+ "aria-label": "bar"
+ })
+
+ render(component)
+ end
end
end
diff --git a/lookbook/previews/open_project/projects/phase_status_button_component_preview.rb b/lookbook/previews/open_project/projects/phase_status_button_component_preview.rb
new file mode 100644
index 000000000000..0183310f5e8e
--- /dev/null
+++ b/lookbook/previews/open_project/projects/phase_status_button_component_preview.rb
@@ -0,0 +1,48 @@
+# 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, see .
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+module OpenProject::Projects
+ # @logical_path OpenProject/Projects
+ class PhaseStatusButtonComponentPreview < ViewComponent::Preview
+ # !! Currently nothing happens when changing the status by
+ # !! clicking the options in the dropdown!!
+ # @param status select [open, in_progress, completed]
+ def playground(status: :open)
+ user = FactoryBot.build_stubbed(:admin)
+ phase = FactoryBot.build_stubbed(:project_phase, status:)
+ render(::Projects::PhaseStatusButtonComponent.new(phase:, user:))
+ end
+
+ def edit_disabled(status: :open)
+ user = FactoryBot.build_stubbed(:user)
+ phase = FactoryBot.build_stubbed(:project_phase, status:)
+ render(::Projects::PhaseStatusButtonComponent.new(phase:, user:))
+ end
+ end
+end
diff --git a/modules/overviews/app/components/overviews/project_phases/item_component.html.erb b/modules/overviews/app/components/overviews/project_phases/item_component.html.erb
index cd3882097a2e..2a25b8adce37 100644
--- a/modules/overviews/app/components/overviews/project_phases/item_component.html.erb
+++ b/modules/overviews/app/components/overviews/project_phases/item_component.html.erb
@@ -1,21 +1,25 @@
<%=
flex_layout(
- align_items: :flex_start,
- justify_content: :space_between,
classes: "op-project-life-cycle-container",
data: {
- test_selector: "project-life-cycle-#{model.id}"
+ test_selector: "project-life-cycle-#{phase.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:)
+ life_cycle_container.with_row(flex_layout: true, align_items: :center) do |row|
+ row.with_column(mr: 1, flex_layout: true) do |text_and_icon|
+ text_and_icon.with_column(mr: 1, classes: icon_color_class) do
+ render Primer::Beta::Octicon.new(icon:)
+ end
+
+ text_and_icon.with_column do
+ render(Primer::Beta::Text.new(font_weight: :bold)) do
+ text
+ end
+ end
end
row.with_column do
- render(Primer::Beta::Text.new(font_weight: :bold)) do
- text
- end
+ render(Projects::PhaseStatusButtonComponent.new(phase: phase, user: @user))
end
end
@@ -24,7 +28,7 @@
if date_range_not_set?
render(Primer::Beta::Text.new) { t("placeholders.default") }
else
- render Projects::PhaseComponent.new(phase: model)
+ render Projects::PhaseComponent.new(phase: phase)
end
end
end
diff --git a/modules/overviews/app/components/overviews/project_phases/item_component.rb b/modules/overviews/app/components/overviews/project_phases/item_component.rb
index 735d52a175fe..d16b910e1fd8 100644
--- a/modules/overviews/app/components/overviews/project_phases/item_component.rb
+++ b/modules/overviews/app/components/overviews/project_phases/item_component.rb
@@ -33,10 +33,19 @@ class ItemComponent < ApplicationComponent
include ApplicationHelper
include OpPrimer::ComponentHelpers
+ attr_reader :phase, :user
+
+ def initialize(phase:, user:)
+ super()
+
+ @phase = phase
+ @user = user
+ end
+
private
def date_range_not_set?
- model.date_range_not_set?
+ phase.date_range_not_set?
end
def icon
@@ -44,21 +53,21 @@ def icon
end
def icon_color_class
- helpers.hl_foreground_class("project_phase_definition", model.definition)
+ helpers.hl_foreground_class("project_phase_definition", phase.definition)
end
def text
- model.name
+ phase.name
end
def authorized_edit_link
if allowed_to_edit?
Primer::Beta::Link.new(
- href: edit_project_phase_path(model),
+ href: edit_project_phase_path(phase),
classes: "hover-input",
data: { controller: "async-dialog" },
aria: { label: I18n.t(:label_edit) },
- test_selector: "project-life-cycle-edit-button-#{model.id}"
+ test_selector: "project-life-cycle-edit-button-#{phase.id}"
)
else
Primer::Content.new
@@ -66,7 +75,7 @@ def authorized_edit_link
end
def allowed_to_edit?
- User.current.allowed_in_project?(:edit_project_phases, model.project)
+ user.allowed_in_project?(:edit_project_phases, phase.project)
end
end
end
diff --git a/modules/overviews/app/components/overviews/project_phases/show_component.html.erb b/modules/overviews/app/components/overviews/project_phases/show_component.html.erb
index 180acce00ae3..f0f096f2069a 100644
--- a/modules/overviews/app/components/overviews/project_phases/show_component.html.erb
+++ b/modules/overviews/app/components/overviews/project_phases/show_component.html.erb
@@ -9,12 +9,13 @@
section.with_title { t("label_life_cycle_step_plural") }
flex_layout do |details_container|
- @phases.each_with_index do |life_cycle_step, i|
+ @phases.each_with_index do |phase, i|
margin = i == @phases.size - 1 ? 0 : 3
details_container.with_row(mb: margin) do
render(
Overviews::ProjectPhases::ItemComponent.new(
- life_cycle_step
+ phase:,
+ user: @user
)
)
end
diff --git a/modules/overviews/app/components/overviews/project_phases/show_component.rb b/modules/overviews/app/components/overviews/project_phases/show_component.rb
index 839daefe3d79..78ace376b9e3 100644
--- a/modules/overviews/app/components/overviews/project_phases/show_component.rb
+++ b/modules/overviews/app/components/overviews/project_phases/show_component.rb
@@ -34,10 +34,11 @@ class ShowComponent < ApplicationComponent
include OpPrimer::ComponentHelpers
include OpTurbo::Streamable
- def initialize(project:)
+ def initialize(project:, user:)
super
@project = project
+ @user = user
@phases = @project.available_phases.eager_load(:definition)
end
end
diff --git a/modules/overviews/app/components/overviews/project_phases/side_panel_component.html.erb b/modules/overviews/app/components/overviews/project_phases/side_panel_component.html.erb
index c97ad633b94c..9b84a7fe6102 100644
--- a/modules/overviews/app/components/overviews/project_phases/side_panel_component.html.erb
+++ b/modules/overviews/app/components/overviews/project_phases/side_panel_component.html.erb
@@ -6,7 +6,7 @@
test_selector: "project-life-cycle-sidebar-async-content"
)
) do |panel|
- panel.with_section(Overviews::ProjectPhases::ShowComponent.new(project: @project))
+ panel.with_section(Overviews::ProjectPhases::ShowComponent.new(project: @project, user: @user))
end
end
%>
diff --git a/modules/overviews/app/components/overviews/project_phases/side_panel_component.rb b/modules/overviews/app/components/overviews/project_phases/side_panel_component.rb
index e5b54fbde246..12a633075882 100644
--- a/modules/overviews/app/components/overviews/project_phases/side_panel_component.rb
+++ b/modules/overviews/app/components/overviews/project_phases/side_panel_component.rb
@@ -34,14 +34,17 @@ class SidePanelComponent < ApplicationComponent
include OpPrimer::ComponentHelpers
include OpTurbo::Streamable
+ attr_reader :user
+
def initialize(project:)
super
@project = project
+ @user = User.current
end
def render?
- User.current.allowed_in_project?(:view_project_phases, @project) &&
+ user.allowed_in_project?(:view_project_phases, @project) &&
@project.phases.active.any?
end
end
diff --git a/modules/overviews/app/controllers/overviews/project_phases_controller.rb b/modules/overviews/app/controllers/overviews/project_phases_controller.rb
index 492f84032ebb..abc62a073b40 100644
--- a/modules/overviews/app/controllers/overviews/project_phases_controller.rb
+++ b/modules/overviews/app/controllers/overviews/project_phases_controller.rb
@@ -60,7 +60,7 @@ def update
.call(permitted_params.project_phase)
component = if service_call.success?
- Overviews::ProjectPhases::SidePanelComponent.new(project: @project)
+ Overviews::ProjectPhases::SidePanelComponent.new(project: @project, user: current_user)
else
Overviews::ProjectPhases::EditComponent.new(service_call.result)
end
@@ -74,7 +74,7 @@ def update
def find_project_phase_and_project
@project_phase = Project::Phase.where(active: true)
.eager_load(:definition, :project)
- .find(params[:id])
+ .find(params.expect(:id))
@project = @project_phase.project
end
end
diff --git a/modules/overviews/spec/components/overviews/project_phases/show_component_spec.rb b/modules/overviews/spec/components/overviews/project_phases/show_component_spec.rb
index 2699b6981665..88162ebc8fec 100644
--- a/modules/overviews/spec/components/overviews/project_phases/show_component_spec.rb
+++ b/modules/overviews/spec/components/overviews/project_phases/show_component_spec.rb
@@ -43,7 +43,7 @@ def render_component(...)
current_user { user }
subject(:rendered_component) do
- render_component(project:)
+ render_component(project:, user:)
end
it "renders a section" do
diff --git a/spec/components/projects/phase_status_button_component_spec.rb b/spec/components/projects/phase_status_button_component_spec.rb
new file mode 100644
index 000000000000..569058c6a0d5
--- /dev/null
+++ b/spec/components/projects/phase_status_button_component_spec.rb
@@ -0,0 +1,171 @@
+# 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, see .
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "rails_helper"
+
+RSpec.describe Projects::PhaseStatusButtonComponent, type: :component do
+ include Rails.application.routes.url_helpers
+
+ let(:phase) { build_stubbed(:project_phase, **phase_attributes) }
+ let(:phase_attributes) { {} }
+ let(:user) { build_stubbed(:user) }
+
+ current_user { user }
+
+ subject do
+ render_inline(described_class.new(phase:, user:))
+ page
+ end
+
+ shared_examples "renders nothing" do
+ it "renders nothing" do
+ subject
+
+ expect(rendered_content).to eq ""
+ end
+ end
+
+ context "when the user cannot edit project phases" do
+ it_behaves_like "renders nothing"
+
+ context "with a status other than the default" do
+ let(:phase_attributes) { { status: :completed } }
+
+ it_behaves_like "renders nothing"
+ end
+
+ context "when the user can only view project phases" do
+ before do
+ mock_permissions_for(user) do |mock|
+ mock.allow_in_project(:view_project_phases, project: phase.project)
+ end
+ end
+
+ it_behaves_like "renders nothing"
+ end
+
+ context "when the user can only edit project attributes, not phases" do
+ before do
+ mock_permissions_for(user) do |mock|
+ mock.allow_in_project(:edit_project, project: phase.project)
+ end
+ end
+
+ it_behaves_like "renders nothing"
+ end
+ end
+
+ context "when the user can edit project phases" do
+ before do
+ mock_permissions_for(user) do |mock|
+ mock.allow_in_project(:edit_project_phases, project: phase.project)
+ end
+ end
+
+ it "renders component wrapper" do
+ expect(subject).to have_element id: "projects-phase-status-button-component-#{phase.id}", count: 1
+ end
+
+ context "without a status" do
+ let(:phase_attributes) { { status: nil } }
+
+ it_behaves_like "renders nothing"
+ end
+
+ context "with the default status" do
+ it "renders an enabled button for the open status" do
+ expect(subject).to have_button "Open", disabled: false
+ end
+
+ it "renders a Primer ActionMenu (single variant)" do
+ expect(subject).to have_element "action-menu", "data-select-variant": "none"
+ expect(subject).to have_test_selector "phase-status-action-menu-#{phase.id}"
+ end
+
+ it "renders one menu item per status and marks the current one" do
+ subject
+
+ expect(page).to have_menu do
+ expect(page).to have_selector :menuitem, count: 3
+ expect(page).to have_selector :menuitem, text: "Open", aria: { current: true }
+ expect(page).to have_selector :menuitem, text: "In progress"
+ expect(page).to have_selector :menuitem, text: "Completed"
+ end
+ end
+
+ it "links every status to the phase status route using PUT" do
+ subject
+
+ expect(page).to have_menu do
+ { "Open" => "open", "In progress" => "in_progress", "Completed" => "completed" }.each do |label, code|
+ expect(page).to have_selector(:menuitem, text: label) do |link|
+ expect(link[:"data-turbo-method"]).to eq "put"
+ expect(link[:href]).to eq(project_phase_status_path(phase, status: code))
+ end
+ end
+ end
+ end
+ end
+
+ context "with an in progress phase" do
+ let(:phase_attributes) { { status: :in_progress } }
+
+ it "renders an enabled button for that status" do
+ expect(subject).to have_button "In progress", disabled: false
+ end
+
+ it "marks only that status as current" do
+ subject
+
+ expect(page).to have_menu do
+ expect(page).to have_selector :menuitem, text: "In progress", aria: { current: true }
+ expect(page).to have_no_selector :menuitem, text: "Open", aria: { current: true }
+ expect(page).to have_no_selector :menuitem, text: "Completed", aria: { current: true }
+ end
+ end
+ end
+
+ context "with a completed phase" do
+ let(:phase_attributes) { { status: :completed } }
+
+ it "renders an enabled button for that status" do
+ expect(subject).to have_button "Completed", disabled: false
+ end
+
+ it "marks only that status as current" do
+ subject
+
+ expect(page).to have_menu do
+ expect(page).to have_selector :menuitem, text: "Completed", aria: { current: true }
+ expect(page).to have_no_selector :menuitem, text: "Open", aria: { current: true }
+ end
+ end
+ end
+ end
+end
diff --git a/spec/contracts/project_phases/update_status_contract_spec.rb b/spec/contracts/project_phases/update_status_contract_spec.rb
new file mode 100644
index 000000000000..ce2752edd30c
--- /dev/null
+++ b/spec/contracts/project_phases/update_status_contract_spec.rb
@@ -0,0 +1,122 @@
+# 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, see .
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "spec_helper"
+require "contracts/shared/model_contract_shared_context"
+
+RSpec.describe ProjectPhases::UpdateStatusContract do
+ include_context "ModelContract shared context"
+
+ let(:user) { build_stubbed(:user) }
+ let(:phase) { build_stubbed(:project_phase) }
+ let(:project) { phase.project }
+
+ subject(:contract) { described_class.new(phase, user) }
+
+ context "with authorized user" do
+ before do
+ mock_permissions_for(user) do |mock|
+ mock.allow_in_project(:edit_project_phases, project:)
+ end
+ end
+
+ describe "contract validations" do
+ it_behaves_like "contract is valid"
+ it_behaves_like "contract reuses the model errors"
+
+ Project::Phase.statuses.each_key do |status|
+ context "when changing the status to #{status}" do
+ before { phase.status = status }
+
+ it_behaves_like "contract is valid"
+ end
+ end
+
+ context "when changing the status to an unknown value" do
+ before { phase.status = "bogus" }
+
+ it_behaves_like "contract is invalid", status: :inclusion
+ end
+
+ context "when trying to change attributes other than the status" do
+ {
+ start_date: Date.current - 3,
+ finish_date: Date.current + 3,
+ active: false,
+ duration: 42
+ }.each do |attribute, value|
+ context "with #{attribute}" do
+ before { phase.public_send(:"#{attribute}=", value) }
+
+ it_behaves_like "contract is invalid", attribute => :error_readonly
+ end
+ end
+
+ context "when also changing the status" do
+ before do
+ phase.status = "completed"
+ phase.start_date = Date.current - 3
+ end
+
+ it_behaves_like "contract is invalid", start_date: :error_readonly, status: []
+ end
+ end
+ end
+ end
+
+ context "with unauthorized user" do
+ it_behaves_like "contract user is unauthorized"
+
+ context "when changing the status" do
+ before { phase.status = "completed" }
+
+ it_behaves_like "contract user is unauthorized"
+ end
+
+ context "when the user may only view project phases" do
+ before do
+ mock_permissions_for(user) do |mock|
+ mock.allow_in_project(:view_project_phases, project:)
+ end
+ end
+
+ it_behaves_like "contract user is unauthorized"
+ end
+
+ context "when the user may only edit the project, not its phases" do
+ before do
+ mock_permissions_for(user) do |mock|
+ mock.allow_in_project(:edit_project, project:)
+ end
+ end
+
+ it_behaves_like "contract user is unauthorized"
+ end
+ end
+end
diff --git a/spec/features/components/lookbook_accessibility_spec.rb b/spec/features/components/lookbook_accessibility_spec.rb
index a5ed8803e093..ff3c57fb80b0 100644
--- a/spec/features/components/lookbook_accessibility_spec.rb
+++ b/spec/features/components/lookbook_accessibility_spec.rb
@@ -36,7 +36,9 @@
# To mark only a specific preview instead of all previews for that component, add the name separated with a `#`
# "OpPrimer::StatusButtonComponentPreview#playground" => "Needs design change",
AXE_KNOWN_VIOLATIONS = {
- "OpPrimer::StatusButtonComponentPreview" => "Needs design change"
+ "OpPrimer::StatusButtonComponentPreview#playground" => "Needs design change",
+ "OpPrimer::StatusButtonComponentPreview#with_icon" => "Needs design change",
+ "OpPrimer::StatusButtonComponentPreview#with_description" => "Needs design change"
}.freeze
RSpec.describe "Lookbook previews accessibility", :component_preview, :js, :selenium do
diff --git a/spec/features/projects/life_cycle/overview_page/status_spec.rb b/spec/features/projects/life_cycle/overview_page/status_spec.rb
new file mode 100644
index 000000000000..c4c7c78e40ab
--- /dev/null
+++ b/spec/features/projects/life_cycle/overview_page/status_spec.rb
@@ -0,0 +1,153 @@
+# 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, see .
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "spec_helper"
+require_relative "shared_context"
+
+RSpec.describe "Change project phase status on project overview page", :js do
+ include_context "with seeded projects and phases"
+
+ let(:overview_page) { Pages::Projects::Show.new(project) }
+ let(:permissions) { %i[view_project view_project_phases edit_project_phases] }
+ let(:user) { create(:user, member_with_permissions: { project => permissions }) }
+
+ let(:initial_statuses) { %w[open open open open] }
+ let(:inactive_phases) { [] }
+
+ current_user { user }
+
+ before do
+ project_life_cycles.zip(initial_statuses) { |phase, status| phase.update_columns(status:) }
+ inactive_phases.each { it.update_columns(active: false) }
+
+ overview_page.visit_page
+ end
+
+ def change_status(life_cycle, from:, to:)
+ wait_for_turbo_stream do
+ overview_page.within_life_cycle_container(life_cycle) do
+ click_button from
+ click_on to
+ end
+ end
+ end
+
+ def expect_status_buttons(*labels)
+ project_life_cycles.zip(labels).each do |life_cycle, label|
+ overview_page.within_life_cycle_container(life_cycle) do
+ expect(page).to have_button(label)
+ end
+ end
+ end
+
+ def stored_statuses
+ project_life_cycles.map { it.reload.status }
+ end
+
+ describe "permissions" do
+ context "with permission to edit project phases" do
+ it "shows enabled status buttons" do
+ overview_page.within_life_cycle_sidebar do
+ expect(page).to have_button("Open", disabled: false, count: 4)
+ end
+ end
+ end
+
+ context "with permission to view project phases only" do
+ let(:permissions) { %i[view_project view_project_phases] }
+
+ it "shows the phases without status buttons" do
+ overview_page.within_life_cycle_sidebar do
+ expect(page).to have_text(life_cycle_initiating.name)
+ expect(page).to have_no_button("Open")
+ expect(page).to have_no_css("action-menu")
+ end
+ end
+ end
+
+ context "with permission to edit the project but not its phases" do
+ let(:permissions) { %i[view_project view_project_phases edit_project] }
+
+ it "shows the phases without status buttons" do
+ overview_page.within_life_cycle_sidebar do
+ expect(page).to have_text(life_cycle_initiating.name)
+ expect(page).to have_no_button("Open")
+ expect(page).to have_no_css("action-menu")
+ end
+ end
+ end
+ end
+
+ describe "changing the status of a phase" do
+ context "when setting a phase to in progress" do
+ it "completes the earlier phases, opens the later ones and refreshes all buttons" do
+ change_status(life_cycle_executing, from: "Open", to: "In progress")
+
+ expect_flash(message: I18n.t(:notice_successful_update))
+ expect_status_buttons("Completed", "Completed", "In progress", "Open")
+ expect(stored_statuses).to eq %w[completed completed in_progress open]
+ end
+ end
+
+ context "when setting a phase to completed" do
+ let(:initial_statuses) { %w[completed in_progress open open] }
+
+ it "completes the earlier phases and leaves the later ones untouched" do
+ change_status(life_cycle_executing, from: "Open", to: "Completed")
+
+ expect_flash(message: I18n.t(:notice_successful_update))
+ expect_status_buttons("Completed", "Completed", "Completed", "Open")
+ expect(stored_statuses).to eq %w[completed completed completed open]
+ end
+ end
+
+ context "when setting a phase to open" do
+ let(:initial_statuses) { %w[completed completed in_progress open] }
+
+ it "reopens the later phases and leaves the earlier ones untouched" do
+ change_status(life_cycle_planning, from: "Completed", to: "Open")
+
+ expect_flash(message: I18n.t(:notice_successful_update))
+ expect_status_buttons("Completed", "Open", "Open", "Open")
+ expect(stored_statuses).to eq %w[completed open open open]
+ end
+ end
+
+ context "when a phase is inactive for this project" do
+ let(:inactive_phases) { [life_cycle_planning] }
+
+ it "does not change the inactive phase" do
+ change_status(life_cycle_closing, from: "Open", to: "In progress")
+
+ expect_flash(message: I18n.t(:notice_successful_update))
+ expect(stored_statuses).to eq %w[completed open completed in_progress]
+ end
+ end
+ end
+end
diff --git a/spec/services/project_phases/update_status_service_spec.rb b/spec/services/project_phases/update_status_service_spec.rb
new file mode 100644
index 000000000000..bf9f20b2658f
--- /dev/null
+++ b/spec/services/project_phases/update_status_service_spec.rb
@@ -0,0 +1,169 @@
+# 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, see .
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "spec_helper"
+
+RSpec.describe ProjectPhases::UpdateStatusService, type: :model do
+ shared_let(:project) { create(:project) }
+
+ let(:user) { create(:user) }
+ let(:initial_statuses) { %w[open open open open] }
+ let(:target_index) { 0 }
+ let(:permissions) { [:edit_project_phases] }
+
+ # Each factory call creates its own definition, so positions follow creation order.
+ let!(:phases) do
+ initial_statuses.map { |status| create(:project_phase, project:, status:) }
+ end
+ let(:target) { phases[target_index] }
+ let(:service) { described_class.new(user:, model: target) }
+
+ def statuses
+ phases.map { it.reload.status }
+ end
+
+ before do
+ mock_permissions_for(user) do |mock|
+ permissions.each { |permission| mock.allow_in_project(permission, project:) }
+ end
+
+ # Project#available_phases is scoped through Project.allowed_to (a SQL scope that
+ # mock_permissions_for does not cover), so without these stubs it is empty.
+ allow(Project).to receive(:allowed_to).with(user, :view_project_attributes).and_return(Project.all)
+ allow(Project).to receive(:allowed_to).with(user, :view_project_phases).and_return(Project.all)
+ end
+
+ describe "changing the status of a single phase" do
+ let(:target_index) { 1 }
+
+ it "persists the new status on the phase" do
+ expect(service.call(status: "in_progress")).to be_success
+ expect(target.reload.status).to eq "in_progress"
+ end
+
+ it "does not touch phases of other projects" do
+ other = create(:project_phase, status: "open")
+
+ expect { service.call(status: "completed") }.not_to change { other.reload.status }
+ end
+ end
+
+ describe "cascading" do
+ # [initial statuses, index of the phase being changed, new status, expected statuses]
+ {
+ "open" => [
+ # Reopens everything after the phase, leaves earlier phases alone
+ [%w[completed completed in_progress open], 1, "open", %w[completed open open open]],
+ [%w[in_progress open open open], 0, "open", %w[open open open open]],
+ [%w[completed completed completed completed], 1, "open", %w[completed open open open]],
+ [%w[completed in_progress open open], 2, "open", %w[completed in_progress open open]],
+ # Nothing has started yet: nothing to cascade
+ [%w[open open open open], 2, "open", %w[open open open open]]
+ ],
+ "in_progress" => [
+ # Completes earlier phases, opens later ones
+ [%w[open open open open], 2, "in_progress", %w[completed completed in_progress open]],
+ [%w[completed completed in_progress open], 1, "in_progress", %w[completed in_progress open open]],
+ [%w[completed completed completed completed], 1, "in_progress", %w[completed in_progress open open]],
+ [%w[open open open open], 0, "in_progress", %w[in_progress open open open]]
+ ],
+ "completed" => [
+ # Completes earlier phases, never opens anything
+ [%w[open open open open], 2, "completed", %w[completed completed completed open]],
+ [%w[open open in_progress open], 1, "completed", %w[completed completed in_progress open]],
+ [%w[completed in_progress open open], 3, "completed", %w[completed completed completed completed]],
+ [%w[open open open open], 0, "completed", %w[completed open open open]]
+ ]
+ }.each do |status, cases|
+ context "when setting a phase to #{status}" do
+ cases.each do |initial, index, new_status, expected|
+ context "with phases #{initial.join(', ')} and changing phase #{index + 1}" do
+ let(:initial_statuses) { initial }
+ let(:target_index) { index }
+
+ it "results in #{expected.join(', ')}" do
+ expect(service.call(status: new_status)).to be_success
+ expect(statuses).to eq expected
+ end
+ end
+ end
+ end
+ end
+
+ context "with an inactive phase in between" do
+ let!(:phases) do
+ [
+ create(:project_phase, project:, status: "open"),
+ create(:project_phase, :inactive, project:, status: "open"),
+ create(:project_phase, project:, status: "open"),
+ create(:project_phase, project:, status: "open")
+ ]
+ end
+ let(:target_index) { 2 }
+
+ it "leaves the inactive phase untouched" do
+ expect(service.call(status: "in_progress")).to be_success
+ expect(statuses).to eq %w[completed open in_progress open]
+ end
+ end
+
+ context "when a phase of another project exists" do
+ let!(:other_phases) do
+ create_list(:project_phase, 2, status: "open")
+ end
+ let(:target_index) { 3 }
+
+ it "does not cascade into the other project" do
+ expect { service.call(status: "completed") }
+ .not_to change { other_phases.map { it.reload.status } }
+ end
+ end
+ end
+
+ describe "failures" do
+ let(:initial_statuses) { %w[open open open open] }
+ let(:target_index) { 2 }
+
+ context "without permission to edit project phases" do
+ let(:permissions) { [] }
+
+ it "fails and changes nothing" do
+ expect(service.call(status: "in_progress")).to be_failure
+ expect(statuses).to eq initial_statuses
+ end
+ end
+
+ context "with an unknown status" do
+ it "fails and changes nothing" do
+ expect(service.call(status: "bogus")).to be_failure
+ expect(statuses).to eq initial_statuses
+ end
+ end
+ end
+end