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