From 6dca1a4afe89909178a9b3e17094f67d057009ef Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Mon, 19 Oct 2026 14:46:30 +0200 Subject: [PATCH 01/19] [OP-20396] States for project lifecycle phases https://community.openproject.org/wp/OP-20396 From dbba8e68c5892754ad01928001e999b3eec1afcc Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Mon, 19 Oct 2026 15:29:08 +0200 Subject: [PATCH 02/19] [OP-20396] Adds status to the project phase model Adds the required fields to the Project Phase model, and adds some constants for the new phase statuses Adds a migration for that --- app/constants/projects/phase_statuses.rb | 45 ++++++++++++ app/models/project/phase.rb | 2 + .../project_phases/update_status_service.rb | 69 +++++++++++++++++++ ...1019124852_add_status_to_project_phases.rb | 37 ++++++++++ 4 files changed, 153 insertions(+) create mode 100644 app/constants/projects/phase_statuses.rb create mode 100644 app/services/project_phases/update_status_service.rb create mode 100644 db/migrate/20261019124852_add_status_to_project_phases.rb diff --git a/app/constants/projects/phase_statuses.rb b/app/constants/projects/phase_statuses.rb new file mode 100644 index 000000000000..bba59bdf63f9 --- /dev/null +++ b/app/constants/projects/phase_statuses.rb @@ -0,0 +1,45 @@ +# 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: "#1F883D", icon: "issue-opened") + IN_PROGRESS = Status.new(code: :in_progress, hexcode: "#1F883D", icon: "issue-opened") + COMPLETED = Status.new(code: :completed, hexcode: "#1F883D", icon: "issue-opened") + + AVAILABLE = [OPEN, IN_PROGRESS, COMPLETED].freeze + end +end diff --git a/app/models/project/phase.rb b/app/models/project/phase.rb index 7caed4ee7ccc..61aac9608664 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: 0, in_progress: 1, completed: 2 } + 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..6ba3c8478b8d --- /dev/null +++ b/app/services/project_phases/update_status_service.rb @@ -0,0 +1,69 @@ +# 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::BaseContracted + alias_method :phase, :model + + def initialize(user:, phase:, contract_class: nil, contract_options: {}) + super(user:, contract_class:, contract_options:) + self.model = phase + end + + private + + def persist(service_call) + Project::Phase.transaction do + phase.update!(status: params[:status]) + cascade + end + phase.project.touch_and_save_journals + service_call + end + + def cascade + phase.project.available_phases.each do |other| + next if other == phase + + target = cascaded_status(earlier: other.position < phase.position) + other.update!(status: target) if target && other.status != target + end + end + + def cascaded_status(earlier:) + case phase.status + when "open" then earlier ? nil : "open" + when "in_progress" then earlier ? "completed" : "open" + when "completed" then earlier ? "completed" : nil + end + end + + def default_contract_class = ProjectPhases::UpdateStatusContract + end +end 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..ca25e0a6841c --- /dev/null +++ b/db/migrate/20261019124852_add_status_to_project_phases.rb @@ -0,0 +1,37 @@ +# 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, :integer, null: false, default: 0 + add_column :project_phase_journals, :status, :integer, null: false, default: 0 + + add_index :project_phases, :status + end +end From b00afef20f340d90919710c84ca6927aa462cd9d Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Mon, 19 Oct 2026 17:16:41 +0200 Subject: [PATCH 03/19] [OP-20396] Building the UI Adds a new Project::PhaseStatusButtom component, mirroring the existing Project::StatusButton component. Registers a new highlighter (so far unused) Adds translations. Adds the option to render only an IconButton in the OpPrimer::StatusButton component. --- .../op_primer/status_button_component.rb | 8 +- .../phase_status_button_component.html.erb | 27 +++++++ .../projects/phase_status_button_component.rb | 73 +++++++++++++++++++ .../projects/status_button_component.html.erb | 2 +- app/constants/projects/phase_statuses.rb | 8 +- app/helpers/project_phase_status_helper.rb | 57 +++++++++++++++ config/initializers/resource_colors.rb | 1 + modules/grids/config/locales/js-en.yml | 9 +++ .../project_phases/item_component.html.erb | 4 + 9 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 app/components/projects/phase_status_button_component.html.erb create mode 100644 app/components/projects/phase_status_button_component.rb create mode 100644 app/helpers/project_phase_status_helper.rb diff --git a/app/components/op_primer/status_button_component.rb b/app/components/op_primer/status_button_component.rb index 5e4b931a6d2f..c342f0684a9c 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 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..fb1d1849f890 --- /dev/null +++ b/app/components/projects/phase_status_button_component.html.erb @@ -0,0 +1,27 @@ +<%= + component_wrapper do + flex_layout do |container| + container.with_row do + flex_layout(align_items: :center) do |flex| + flex.with_column do + render( + OpPrimer::StatusButtonComponent.new( + current_status: build_item(@status), + items: build_items, + button_arguments: { + icon_only: true, + title: project_phase_status_name(@status.code), + size: :medium, + scheme: :invisible, + style: :foreground, + icon: @status.icon, + "aria-label": project_phase_status_name(@status.code) + } + ) + ) + end + end + 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..03b04afec395 --- /dev/null +++ b/app/components/projects/phase_status_button_component.rb @@ -0,0 +1,73 @@ +# 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 + include ProjectPhaseStatusHelper + + def initialize(phase:, **system_arguments) + super + @status = Projects::PhaseStatuses.find(phase.status) + @system_arguments = system_arguments + end + + def before_render + @system_arguments[:classes] = class_names( + @system_arguments[:classes], + helpers.hl_background_muted_class(:project_phase_status, @status.id) + ) + end + + def render? = @status.present? + + private + + 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_status, + color_ref: status.id, + icon: status.icon, + item_id: status.id, + tag: :a, + content_arguments: { + data: { turbo_method: status.value ? :put : :delete }, + aria: { current: (true if status == @status) } + } + ) + end +end diff --git a/app/components/projects/status_button_component.html.erb b/app/components/projects/status_button_component.html.erb index 5f29a0f1a4a3..c1099abc9809 100644 --- a/app/components/projects/status_button_component.html.erb +++ b/app/components/projects/status_button_component.html.erb @@ -12,7 +12,7 @@ disabled: !edit_enabled?, button_arguments: { title: t("projects.status.button_edit"), - size: @size + size: :medium } ) ) diff --git a/app/constants/projects/phase_statuses.rb b/app/constants/projects/phase_statuses.rb index bba59bdf63f9..ad6102aa7616 100644 --- a/app/constants/projects/phase_statuses.rb +++ b/app/constants/projects/phase_statuses.rb @@ -37,9 +37,13 @@ def color = Color.new(hexcode:) end OPEN = Status.new(code: :open, hexcode: "#1F883D", icon: "issue-opened") - IN_PROGRESS = Status.new(code: :in_progress, hexcode: "#1F883D", icon: "issue-opened") - COMPLETED = Status.new(code: :completed, hexcode: "#1F883D", icon: "issue-opened") + IN_PROGRESS = Status.new(code: :in_progress, hexcode: "#1F883D", icon: "play") + COMPLETED = Status.new(code: :completed, hexcode: "#1F883D", 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/helpers/project_phase_status_helper.rb b/app/helpers/project_phase_status_helper.rb new file mode 100644 index 000000000000..067123bed87b --- /dev/null +++ b/app/helpers/project_phase_status_helper.rb @@ -0,0 +1,57 @@ +# 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 ProjectPhaseStatusHelper + ## + # Returns the CSS class (BEM modifier) for the Project Status. + # + # @param status_code [String | Symbol | nil] Project Status code + # @return [String] the CSS class. + def project_phase_status_css_class(status_code) + "-#{status_code&.to_s.dasherize}" + end + + ## + # Returns the localized Project Status name. + # + # @param status_code [String | Symbol | nil] Project Status code + # @return [String] the localized name. + def project_phase_status_name(status_code) + I18n.t(status_code, scope: "js.grid.widgets.project_phase_status.title") + end + + ## + # Returns the localized Project Status description. + # + # @param status_code [String | Symbol | nil] Project Status code + # @return [String] the localized description. + def project_phase_status_description(status_code) + I18n.t(status_code, scope: "js.grid.widgets.project_phase_status.description") + end +end 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/modules/grids/config/locales/js-en.yml b/modules/grids/config/locales/js-en.yml index 8943440d59f1..d133a91155c7 100644 --- a/modules/grids/config/locales/js-en.yml +++ b/modules/grids/config/locales/js-en.yml @@ -34,6 +34,15 @@ en: off_track: 'Off track' on_track: 'On track' title: 'Status' + project_phase_status: + title: + open: 'Open' + in_progress: 'In progress' + completed: 'Completed' + description: + open: 'Re-opening a phase will also open all future phases.' + in_progress: 'Marking a phase as complete will complete all previous phases.' + completed: 'Marking a phase as complete will complete all previous phases.' project_timeline: accessible_date_range: "%{start} to %{end}" accessible_gate: "Phase gate %{name}: %{date}" 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..f6f8f30703fb 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 @@ -17,6 +17,10 @@ text end end + + row.with_column do + render(Projects::PhaseStatusButtonComponent.new(phase: model)) + end end life_cycle_container.with_row(w: :full) do From e7c1ba30a37794bdafb206ccd3452f0650dc678d Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Tue, 20 Oct 2026 10:55:45 +0200 Subject: [PATCH 04/19] [OP-20396] Functionality of switching states Updates controllers & add routes to allow changing phase states. Colors of the buttons in the dropdown are now correctly set. --- .../phase_status_button_component.html.erb | 4 ++ .../projects/phase_status_button_component.rb | 17 ++++- .../projects/status_button_component.html.erb | 2 +- app/constants/projects/phase_statuses.rb | 4 +- .../project_phases/update_status_contract.rb | 36 ++++++++++ .../project_phases/status_controller.rb | 68 +++++++++++++++++++ app/models/project/phase.rb | 2 +- .../project_phases/update_status_service.rb | 33 ++++----- config/initializers/permissions.rb | 2 +- config/routes.rb | 1 + .../project_phases/item_component.html.erb | 4 +- 11 files changed, 144 insertions(+), 29 deletions(-) create mode 100644 app/contracts/project_phases/update_status_contract.rb create mode 100644 app/controllers/project_phases/status_controller.rb diff --git a/app/components/projects/phase_status_button_component.html.erb b/app/components/projects/phase_status_button_component.html.erb index fb1d1849f890..2e370327971f 100644 --- a/app/components/projects/phase_status_button_component.html.erb +++ b/app/components/projects/phase_status_button_component.html.erb @@ -10,6 +10,10 @@ items: build_items, button_arguments: { icon_only: true, + classes: class_names( + "phase-status-button", + helpers.hl_foreground_class(:project_phase_status, @status.id) + ), title: project_phase_status_name(@status.code), size: :medium, scheme: :invisible, diff --git a/app/components/projects/phase_status_button_component.rb b/app/components/projects/phase_status_button_component.rb index 03b04afec395..ce471e2fc888 100644 --- a/app/components/projects/phase_status_button_component.rb +++ b/app/components/projects/phase_status_button_component.rb @@ -34,8 +34,12 @@ class Projects::PhaseStatusButtonComponent < ApplicationComponent include Primer::ClassNameHelper include ProjectPhaseStatusHelper + attr_reader :phase + def initialize(phase:, **system_arguments) super + + @phase = phase @status = Projects::PhaseStatuses.find(phase.status) @system_arguments = system_arguments end @@ -43,14 +47,22 @@ def initialize(phase:, **system_arguments) def before_render @system_arguments[:classes] = class_names( @system_arguments[:classes], - helpers.hl_background_muted_class(:project_phase_status, @status.id) + helpers.hl_foreground_class(:project_phase_status, @status.id) ) end + def wrapper_uniq_by + phase + end + def render? = @status.present? private + def edit_enabled? + User.current.allowed_in_project?(:edit_project_phases, phase.project) + end + def build_items Projects::PhaseStatuses::AVAILABLE.map { build_item(it) } end @@ -59,11 +71,12 @@ def build_item(status) OpPrimer::StatusButtonOption.new( name: project_phase_status_name(status.code), description: project_phase_status_description(status.code), - color_namespace: :project_status, + 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) } diff --git a/app/components/projects/status_button_component.html.erb b/app/components/projects/status_button_component.html.erb index c1099abc9809..5f29a0f1a4a3 100644 --- a/app/components/projects/status_button_component.html.erb +++ b/app/components/projects/status_button_component.html.erb @@ -12,7 +12,7 @@ disabled: !edit_enabled?, button_arguments: { title: t("projects.status.button_edit"), - size: :medium + size: @size } ) ) diff --git a/app/constants/projects/phase_statuses.rb b/app/constants/projects/phase_statuses.rb index ad6102aa7616..669ed81bd690 100644 --- a/app/constants/projects/phase_statuses.rb +++ b/app/constants/projects/phase_statuses.rb @@ -36,9 +36,9 @@ def value = code&.to_s def color = Color.new(hexcode:) end - OPEN = Status.new(code: :open, hexcode: "#1F883D", icon: "issue-opened") + 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: "#1F883D", icon: "check-circle") + COMPLETED = Status.new(code: :completed, hexcode: "#25292e", icon: "check-circle") AVAILABLE = [OPEN, IN_PROGRESS, COMPLETED].freeze 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..38a853b4735a --- /dev/null +++ b/app/contracts/project_phases/update_status_contract.rb @@ -0,0 +1,36 @@ +# 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 + delegate :project, to: :model + + def writable_attributes = %w[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..521ce29b731b --- /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[:project_phase_id]) + @project = @phase.project + end + + def refresh_buttons + @project.available_phases.each do |phase| + update_via_turbo_stream( + component: Projects::PhaseStatusButtonComponent.new(phase: phase.reload) + ) + end + end + end +end diff --git a/app/models/project/phase.rb b/app/models/project/phase.rb index 61aac9608664..0b2a8d00465c 100644 --- a/app/models/project/phase.rb +++ b/app/models/project/phase.rb @@ -49,7 +49,7 @@ class Project::Phase < ApplicationRecord attr_readonly :definition_id - enum :status, { open: 0, in_progress: 1, completed: 2 } + enum :status, { open: 0, in_progress: 1, completed: 2 }, validate: true scope :active, -> { where(active: true) } scope :with_timeline_content, -> { diff --git a/app/services/project_phases/update_status_service.rb b/app/services/project_phases/update_status_service.rb index 6ba3c8478b8d..719cbe1f4165 100644 --- a/app/services/project_phases/update_status_service.rb +++ b/app/services/project_phases/update_status_service.rb @@ -28,36 +28,31 @@ #++ module ProjectPhases - class UpdateStatusService < ::BaseServices::BaseContracted - alias_method :phase, :model + class UpdateStatusService < ::BaseServices::Update + delegate :project, to: :model - def initialize(user:, phase:, contract_class: nil, contract_options: {}) - super(user:, contract_class:, contract_options:) - self.model = phase + def after_perform(*) + cascade_to_other_phases + project.touch_and_save_journals + + super end private - def persist(service_call) - Project::Phase.transaction do - phase.update!(status: params[:status]) - cascade - end - phase.project.touch_and_save_journals - service_call - end + def cascade_to_other_phases + project.available_phases.each do |other| + next if other == model - def cascade - phase.project.available_phases.each do |other| - next if other == phase + target = cascaded_status(earlier: other.position < model.position) + next if target.nil? || other.status == target - target = cascaded_status(earlier: other.position < phase.position) - other.update!(status: target) if target && other.status != target + other.update!(status: target) end end def cascaded_status(earlier:) - case phase.status + case model.status when "open" then earlier ? nil : "open" when "in_progress" then earlier ? "completed" : "open" when "completed" then earlier ? "completed" : nil 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/routes.rb b/config/routes.rb index 2434e04a355f..fce458606fa3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -778,6 +778,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/modules/overviews/app/components/overviews/project_phases/item_component.html.erb b/modules/overviews/app/components/overviews/project_phases/item_component.html.erb index f6f8f30703fb..03808d578eb5 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,13 +1,11 @@ <%= flex_layout( - align_items: :flex_start, - justify_content: :space_between, classes: "op-project-life-cycle-container", data: { test_selector: "project-life-cycle-#{model.id}" } ) do |life_cycle_container| - life_cycle_container.with_row(mb: 1, flex_layout: true) do |row| + life_cycle_container.with_row(mb: 1, flex_layout: true, align_items: :center) do |row| row.with_column(mr: 1, classes: icon_color_class) do render Primer::Beta::Octicon.new(icon:) end From f5afec10a77fe49f892d915befd5ea10cd1eaf62 Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Tue, 20 Oct 2026 15:24:55 +0200 Subject: [PATCH 05/19] [OP-20396] Layout adjustments, lookbook preview, & linting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds some space between button & text, so when hovered it doesn’t look broken. Adds a story for the new status button component Cleans up some unnecessary code Linting --- .../projects/phase_status_button_component.rb | 7 ---- app/controllers/project_phase_controller.rb | 0 .../project_phases/status_controller.rb | 2 +- app/helpers/project_phase_status_helper.rb | 2 +- .../phase_status_button_component_preview.rb | 42 +++++++++++++++++++ .../project_phases/item_component.html.erb | 14 ++++--- 6 files changed, 52 insertions(+), 15 deletions(-) create mode 100644 app/controllers/project_phase_controller.rb create mode 100644 lookbook/previews/open_project/projects/phase_status_button_component_preview.rb diff --git a/app/components/projects/phase_status_button_component.rb b/app/components/projects/phase_status_button_component.rb index ce471e2fc888..ed0cafc42ee8 100644 --- a/app/components/projects/phase_status_button_component.rb +++ b/app/components/projects/phase_status_button_component.rb @@ -44,13 +44,6 @@ def initialize(phase:, **system_arguments) @system_arguments = system_arguments end - def before_render - @system_arguments[:classes] = class_names( - @system_arguments[:classes], - helpers.hl_foreground_class(:project_phase_status, @status.id) - ) - end - def wrapper_uniq_by phase end diff --git a/app/controllers/project_phase_controller.rb b/app/controllers/project_phase_controller.rb new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/app/controllers/project_phases/status_controller.rb b/app/controllers/project_phases/status_controller.rb index 521ce29b731b..aa17a33d03f0 100644 --- a/app/controllers/project_phases/status_controller.rb +++ b/app/controllers/project_phases/status_controller.rb @@ -53,7 +53,7 @@ def update private def find_phase - @phase = Project::Phase.where(active: true).find(params[:project_phase_id]) + @phase = Project::Phase.where(active: true).find(params.expect(:project_phase_id)) @project = @phase.project end diff --git a/app/helpers/project_phase_status_helper.rb b/app/helpers/project_phase_status_helper.rb index 067123bed87b..f9c9fd4c578b 100644 --- a/app/helpers/project_phase_status_helper.rb +++ b/app/helpers/project_phase_status_helper.rb @@ -34,7 +34,7 @@ module ProjectPhaseStatusHelper # @param status_code [String | Symbol | nil] Project Status code # @return [String] the CSS class. def project_phase_status_css_class(status_code) - "-#{status_code&.to_s.dasherize}" + "-#{status_code&.to_s&.dasherize}" 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..a029b6321c2a --- /dev/null +++ b/lookbook/previews/open_project/projects/phase_status_button_component_preview.rb @@ -0,0 +1,42 @@ +# 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 + 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 03808d578eb5..242dd0d341d0 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 @@ -6,13 +6,15 @@ } ) do |life_cycle_container| life_cycle_container.with_row(mb: 1, flex_layout: true, align_items: :center) do |row| - row.with_column(mr: 1, classes: icon_color_class) do - render Primer::Beta::Octicon.new(icon:) - end + 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 - row.with_column do - render(Primer::Beta::Text.new(font_weight: :bold)) do - text + text_and_icon.with_column do + render(Primer::Beta::Text.new(font_weight: :bold)) do + text + end end end From 3756b2d72e5124359025bd78e9b37d324bb56890 Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Tue, 20 Oct 2026 15:49:46 +0200 Subject: [PATCH 06/19] [OP-20396] Add lookbook preview for OPPrimer status button component --- .../status_button_component_preview.rb | 32 +++++++++++++++++++ .../phase_status_button_component_preview.rb | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) 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 index a029b6321c2a..55c31e561270 100644 --- a/lookbook/previews/open_project/projects/phase_status_button_component_preview.rb +++ b/lookbook/previews/open_project/projects/phase_status_button_component_preview.rb @@ -30,7 +30,7 @@ module OpenProject::Projects # @logical_path OpenProject/Projects class PhaseStatusButtonComponentPreview < ViewComponent::Preview - # !! Currently nothing happens when changing the status by !! + # !! 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) From 9b193b2f1e145fb89aaffbb31a1b4a296e43ccf0 Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Tue, 20 Oct 2026 16:58:08 +0200 Subject: [PATCH 07/19] [OP-20396] Adding tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tests for the new project-phase status button, and the phase-status service. The phase-status-button is now disabled when the user doesn’t have edit rights. --- .../phase_status_button_component.html.erb | 4 +- .../phase_status_button_component_spec.rb | 179 ++++++++++++++++++ .../update_status_service_spec.rb | 169 +++++++++++++++++ 3 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 spec/components/projects/phase_status_button_component_spec.rb create mode 100644 spec/services/project_phases/update_status_service_spec.rb diff --git a/app/components/projects/phase_status_button_component.html.erb b/app/components/projects/phase_status_button_component.html.erb index 2e370327971f..7a5f2320b577 100644 --- a/app/components/projects/phase_status_button_component.html.erb +++ b/app/components/projects/phase_status_button_component.html.erb @@ -8,10 +8,12 @@ OpPrimer::StatusButtonComponent.new( current_status: build_item(@status), items: build_items, + readonly: !edit_enabled?, + disabled: !edit_enabled?, button_arguments: { icon_only: true, classes: class_names( - "phase-status-button", + "op-phase-status-button", helpers.hl_foreground_class(:project_phase_status, @status.id) ), title: project_phase_status_name(@status.code), 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..49763d91bc56 --- /dev/null +++ b/spec/components/projects/phase_status_button_component_spec.rb @@ -0,0 +1,179 @@ +# 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 + + it "renders component wrapper" do + id = "projects-phase-status-button-component-#{phase.id}" + expect(subject).to have_xpath "//*[@id='#{id}']", count: 1 + end + + context "when the user cannot edit project phases" do + it "renders a disabled button" do + expect(subject).to have_button "Open", disabled: true + end + + it "does not render a Primer ActionMenu" do + expect(subject).not_to have_element "action-menu" + end + + context "with a status other than the default" do + let(:phase_attributes) { { status: :completed } } + + it "renders a disabled button showing that status" do + expect(subject).to have_button "Completed", disabled: true + end + 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 "renders a disabled button" do + expect(subject).to have_button "Open", disabled: true + end + 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 + + 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_element "action-menu", class: "op-status-button" + 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 + + context "when no user is passed" do + subject do + render_inline(described_class.new(phase:)) + page + end + + let(:current) { build_stubbed(:user) } + + current_user { current } + + before do + mock_permissions_for(current) do |mock| + mock.allow_in_project(:edit_project_phases, project: phase.project) + end + end + + it "falls back to the current user" do + expect(subject).to have_button "Open", disabled: false + 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 From 17167364c3344391ab2eb8522841ba4ef4991481 Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Wed, 21 Oct 2026 10:46:36 +0200 Subject: [PATCH 08/19] [OP-20396] Pass in user Follows the project status approach of passing in the user model, making lookbook preview work following the gating to admin users. --- .../projects/phase_status_button_component.rb | 7 ++++--- .../project_phases/status_controller.rb | 2 +- .../project_phases/item_component.html.erb | 6 +++--- .../project_phases/item_component.rb | 21 +++++++++++++------ .../project_phases/show_component.html.erb | 5 +++-- .../project_phases/show_component.rb | 3 ++- .../side_panel_component.html.erb | 2 +- .../project_phases/side_panel_component.rb | 5 ++++- .../overviews/project_phases_controller.rb | 2 +- 9 files changed, 34 insertions(+), 19 deletions(-) diff --git a/app/components/projects/phase_status_button_component.rb b/app/components/projects/phase_status_button_component.rb index ed0cafc42ee8..a050eb5950d6 100644 --- a/app/components/projects/phase_status_button_component.rb +++ b/app/components/projects/phase_status_button_component.rb @@ -34,12 +34,13 @@ class Projects::PhaseStatusButtonComponent < ApplicationComponent include Primer::ClassNameHelper include ProjectPhaseStatusHelper - attr_reader :phase + attr_reader :phase, :user - def initialize(phase:, **system_arguments) + def initialize(phase:, user:, **system_arguments) super @phase = phase + @user = user @status = Projects::PhaseStatuses.find(phase.status) @system_arguments = system_arguments end @@ -53,7 +54,7 @@ def render? = @status.present? private def edit_enabled? - User.current.allowed_in_project?(:edit_project_phases, phase.project) + user.allowed_in_project?(:edit_project_phases, phase.project) end def build_items diff --git a/app/controllers/project_phases/status_controller.rb b/app/controllers/project_phases/status_controller.rb index aa17a33d03f0..53cdae3684c5 100644 --- a/app/controllers/project_phases/status_controller.rb +++ b/app/controllers/project_phases/status_controller.rb @@ -60,7 +60,7 @@ def find_phase def refresh_buttons @project.available_phases.each do |phase| update_via_turbo_stream( - component: Projects::PhaseStatusButtonComponent.new(phase: phase.reload) + component: Projects::PhaseStatusButtonComponent.new(phase: phase.reload, user: current_user) ) 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 242dd0d341d0..c88b77077f6a 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 @@ -2,7 +2,7 @@ flex_layout( 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, align_items: :center) do |row| @@ -19,7 +19,7 @@ end row.with_column do - render(Projects::PhaseStatusButtonComponent.new(phase: model)) + render(Projects::PhaseStatusButtonComponent.new(phase: phase, user: @user)) end end @@ -28,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..1434b22cf4b0 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: 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..204f4aaf129a 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 From 468d6c2a1e2c7813164fabac7532d154239b9cc5 Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Wed, 21 Oct 2026 10:57:51 +0200 Subject: [PATCH 09/19] [OP-20396] Set color of button icon Adds an override to the icon color in the phase-state-button --- frontend/src/global_styles/primer/_overrides.sass | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index 3a11cce6c2c4..6149d4e4326f 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 @@ -165,7 +172,7 @@ ul.SegmentedControl, .Banner @container (max-width: 543.98px) - grid-template-areas: 'visual message close' '. actions actions' + grid-template-areas: 'visual message close' '.actions actions' grid-template-columns: min-content 1fr min-content grid-template-rows: min-content min-content @@ -213,7 +220,6 @@ ul.SegmentedControl, body.-browser-windows.-browser-firefox & @include keep-project-select-actions-clear-of-scrollbar - .op-sidemenu-filterable-tree .TreeViewItemContentText, .TreeViewItemToggle, From f239466882af52ad3d68bd785050cbc8f5293806 Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Wed, 21 Oct 2026 11:17:19 +0200 Subject: [PATCH 10/19] [OP-20396] Cleanup Updates tests to reflect the user model being passed in. Reverts the unrelated changes made to the Sass file (auto-linting applied by my editor on save) Linting. --- .../src/global_styles/primer/_overrides.sass | 3 ++- .../project_phases/item_component.html.erb | 2 +- .../overviews/project_phases_controller.rb | 2 +- .../project_phases/show_component_spec.rb | 2 +- .../phase_status_button_component_spec.rb | 21 ------------------- 5 files changed, 5 insertions(+), 25 deletions(-) diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index 6149d4e4326f..a9f743103e22 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -172,7 +172,7 @@ ul.SegmentedControl, .Banner @container (max-width: 543.98px) - grid-template-areas: 'visual message close' '.actions actions' + grid-template-areas: 'visual message close' '. actions actions' grid-template-columns: min-content 1fr min-content grid-template-rows: min-content min-content @@ -220,6 +220,7 @@ ul.SegmentedControl, body.-browser-windows.-browser-firefox & @include keep-project-select-actions-clear-of-scrollbar + .op-sidemenu-filterable-tree .TreeViewItemContentText, .TreeViewItemToggle, 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 c88b77077f6a..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 @@ -5,7 +5,7 @@ test_selector: "project-life-cycle-#{phase.id}" } ) do |life_cycle_container| - life_cycle_container.with_row(mb: 1, flex_layout: true, align_items: :center) do |row| + 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:) diff --git a/modules/overviews/app/controllers/overviews/project_phases_controller.rb b/modules/overviews/app/controllers/overviews/project_phases_controller.rb index 204f4aaf129a..abc62a073b40 100644 --- a/modules/overviews/app/controllers/overviews/project_phases_controller.rb +++ b/modules/overviews/app/controllers/overviews/project_phases_controller.rb @@ -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 index 49763d91bc56..fbcccfead67b 100644 --- a/spec/components/projects/phase_status_button_component_spec.rb +++ b/spec/components/projects/phase_status_button_component_spec.rb @@ -155,25 +155,4 @@ end end end - - context "when no user is passed" do - subject do - render_inline(described_class.new(phase:)) - page - end - - let(:current) { build_stubbed(:user) } - - current_user { current } - - before do - mock_permissions_for(current) do |mock| - mock.allow_in_project(:edit_project_phases, project: phase.project) - end - end - - it "falls back to the current user" do - expect(subject).to have_button "Open", disabled: false - end - end end From 4484e8e878478d4afaeffa50a959379307c3c151 Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Mon, 26 Oct 2026 15:47:35 +0100 Subject: [PATCH 11/19] [OP-20396] PR feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removes unnecessarily-nested flex layout in rows - Uses test-selector in tests instead of `.op-` classname - StatusButton is not rendered if the user doesn’t have edit access, instead of simply being disabled. Adds a preview for that, and updates tests. --- .../phase_status_button_component.html.erb | 47 +++++++++---------- .../projects/phase_status_button_component.rb | 2 +- .../phase_status_button_component_preview.rb | 6 +++ .../phase_status_button_component_spec.rb | 44 ++++++++++------- 4 files changed, 55 insertions(+), 44 deletions(-) diff --git a/app/components/projects/phase_status_button_component.html.erb b/app/components/projects/phase_status_button_component.html.erb index 7a5f2320b577..9198c48881af 100644 --- a/app/components/projects/phase_status_button_component.html.erb +++ b/app/components/projects/phase_status_button_component.html.erb @@ -2,31 +2,28 @@ component_wrapper do flex_layout do |container| container.with_row do - flex_layout(align_items: :center) do |flex| - flex.with_column do - render( - OpPrimer::StatusButtonComponent.new( - current_status: build_item(@status), - items: build_items, - readonly: !edit_enabled?, - disabled: !edit_enabled?, - button_arguments: { - icon_only: true, - classes: class_names( - "op-phase-status-button", - helpers.hl_foreground_class(:project_phase_status, @status.id) - ), - title: project_phase_status_name(@status.code), - size: :medium, - scheme: :invisible, - style: :foreground, - icon: @status.icon, - "aria-label": project_phase_status_name(@status.code) - } - ) - ) - end - end + render( + OpPrimer::StatusButtonComponent.new( + current_status: build_item(@status), + items: build_items, + button_arguments: { + icon_only: true, + classes: class_names( + "op-phase-status-button", + helpers.hl_foreground_class(:project_phase_status, @status.id) + ), + 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 index a050eb5950d6..6052a7e793ff 100644 --- a/app/components/projects/phase_status_button_component.rb +++ b/app/components/projects/phase_status_button_component.rb @@ -49,7 +49,7 @@ def wrapper_uniq_by phase end - def render? = @status.present? + def render? = @status.present? && edit_enabled? private 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 index 55c31e561270..0183310f5e8e 100644 --- a/lookbook/previews/open_project/projects/phase_status_button_component_preview.rb +++ b/lookbook/previews/open_project/projects/phase_status_button_component_preview.rb @@ -38,5 +38,11 @@ def playground(status: :open) 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/spec/components/projects/phase_status_button_component_spec.rb b/spec/components/projects/phase_status_button_component_spec.rb index fbcccfead67b..2c3d48fc3eca 100644 --- a/spec/components/projects/phase_status_button_component_spec.rb +++ b/spec/components/projects/phase_status_button_component_spec.rb @@ -43,26 +43,24 @@ page end - it "renders component wrapper" do - id = "projects-phase-status-button-component-#{phase.id}" - expect(subject).to have_xpath "//*[@id='#{id}']", count: 1 + shared_examples "does not render" do + it "renders nothing" do + subject + + expect(rendered_content).to be_blank + expect(page).to have_no_element id: "projects-phase-status-button-component-#{phase.id}" + expect(page).to have_no_button + expect(page).to have_no_element "action-menu" + end end context "when the user cannot edit project phases" do - it "renders a disabled button" do - expect(subject).to have_button "Open", disabled: true - end - - it "does not render a Primer ActionMenu" do - expect(subject).not_to have_element "action-menu" - end + include_examples "does not render" context "with a status other than the default" do let(:phase_attributes) { { status: :completed } } - it "renders a disabled button showing that status" do - expect(subject).to have_button "Completed", disabled: true - end + include_examples "does not render" end context "when the user can only edit project attributes, not phases" do @@ -72,9 +70,7 @@ end end - it "renders a disabled button" do - expect(subject).to have_button "Open", disabled: true - end + include_examples "does not render" end end @@ -85,14 +81,26 @@ end end - context "with the default status" do + context "when the phase has no known status" do + before do + allow(Projects::PhaseStatuses).to receive(:find).with(phase.status).and_return(nil) + end + + include_examples "does not render" + end + + it "renders component wrapper" do + expect(subject).to have_element id: "projects-phase-status-button-component-#{phase.id}", count: 1 + 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_element "action-menu", class: "op-status-button" + 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 From 93794a367beefd15d52e8508c20ea1fe9fd2049b Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Mon, 26 Oct 2026 16:26:27 +0100 Subject: [PATCH 12/19] [OP-20396] PR feedback Removes the global helper, moves the helper functions into the component Moves the new translation strings in `config/locales/en.yml`, out of the JS version. --- .../projects/phase_status_button_component.rb | 15 ++++- app/helpers/project_phase_status_helper.rb | 57 ------------------- config/locales/en.yml | 9 +++ modules/grids/config/locales/js-en.yml | 9 --- 4 files changed, 23 insertions(+), 67 deletions(-) delete mode 100644 app/helpers/project_phase_status_helper.rb diff --git a/app/components/projects/phase_status_button_component.rb b/app/components/projects/phase_status_button_component.rb index 6052a7e793ff..f6ee85a3544a 100644 --- a/app/components/projects/phase_status_button_component.rb +++ b/app/components/projects/phase_status_button_component.rb @@ -32,7 +32,6 @@ class Projects::PhaseStatusButtonComponent < ApplicationComponent include OpTurbo::Streamable include OpPrimer::ComponentHelpers include Primer::ClassNameHelper - include ProjectPhaseStatusHelper attr_reader :phase, :user @@ -77,4 +76,18 @@ def build_item(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/helpers/project_phase_status_helper.rb b/app/helpers/project_phase_status_helper.rb deleted file mode 100644 index f9c9fd4c578b..000000000000 --- a/app/helpers/project_phase_status_helper.rb +++ /dev/null @@ -1,57 +0,0 @@ -# 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 ProjectPhaseStatusHelper - ## - # Returns the CSS class (BEM modifier) for the Project Status. - # - # @param status_code [String | Symbol | nil] Project Status code - # @return [String] the CSS class. - def project_phase_status_css_class(status_code) - "-#{status_code&.to_s&.dasherize}" - end - - ## - # Returns the localized Project Status name. - # - # @param status_code [String | Symbol | nil] Project Status code - # @return [String] the localized name. - def project_phase_status_name(status_code) - I18n.t(status_code, scope: "js.grid.widgets.project_phase_status.title") - end - - ## - # Returns the localized Project Status description. - # - # @param status_code [String | Symbol | nil] Project Status code - # @return [String] the localized description. - def project_phase_status_description(status_code) - I18n.t(status_code, scope: "js.grid.widgets.project_phase_status.description") - end -end diff --git a/config/locales/en.yml b/config/locales/en.yml index 0fbb57997d02..d07637e41b29 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -5892,6 +5892,15 @@ en: new: placeholder: "New project list" shared: "Shared project lists" + phase_status: + title: + completed: 'Completed' + in_progress: 'In progress' + open: 'Open' + 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.' settings: actions: label_disable_all: "Disable all" diff --git a/modules/grids/config/locales/js-en.yml b/modules/grids/config/locales/js-en.yml index d133a91155c7..8943440d59f1 100644 --- a/modules/grids/config/locales/js-en.yml +++ b/modules/grids/config/locales/js-en.yml @@ -34,15 +34,6 @@ en: off_track: 'Off track' on_track: 'On track' title: 'Status' - project_phase_status: - title: - open: 'Open' - in_progress: 'In progress' - completed: 'Completed' - description: - open: 'Re-opening a phase will also open all future phases.' - in_progress: 'Marking a phase as complete will complete all previous phases.' - completed: 'Marking a phase as complete will complete all previous phases.' project_timeline: accessible_date_range: "%{start} to %{end}" accessible_gate: "Phase gate %{name}: %{date}" From ed765a8161cfeb1241a236c5d2183702a38ea497 Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Mon, 26 Oct 2026 17:00:50 +0100 Subject: [PATCH 13/19] [OP-20396] PR feedback The phase status enum now uses string instead of integers. Updated the migration. --- app/contracts/project_phases/update_status_contract.rb | 2 +- app/models/project/phase.rb | 2 +- db/migrate/20261019124852_add_status_to_project_phases.rb | 7 +++++-- .../overviews/project_phases/show_component.html.erb | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/contracts/project_phases/update_status_contract.rb b/app/contracts/project_phases/update_status_contract.rb index 38a853b4735a..ee6a3476b812 100644 --- a/app/contracts/project_phases/update_status_contract.rb +++ b/app/contracts/project_phases/update_status_contract.rb @@ -31,6 +31,6 @@ module ProjectPhases class UpdateStatusContract < BaseContract delegate :project, to: :model - def writable_attributes = %w[status] + attribute :status end end diff --git a/app/models/project/phase.rb b/app/models/project/phase.rb index 0b2a8d00465c..40a56abdc8c8 100644 --- a/app/models/project/phase.rb +++ b/app/models/project/phase.rb @@ -49,7 +49,7 @@ class Project::Phase < ApplicationRecord attr_readonly :definition_id - enum :status, { open: 0, in_progress: 1, completed: 2 }, validate: true + enum :status, { open: "open", in_progress: "in_progress", completed: "completed" }, validate: true scope :active, -> { where(active: true) } scope :with_timeline_content, -> { diff --git a/db/migrate/20261019124852_add_status_to_project_phases.rb b/db/migrate/20261019124852_add_status_to_project_phases.rb index ca25e0a6841c..11d9e16c02b0 100644 --- a/db/migrate/20261019124852_add_status_to_project_phases.rb +++ b/db/migrate/20261019124852_add_status_to_project_phases.rb @@ -29,9 +29,12 @@ class AddStatusToProjectPhases < ActiveRecord::Migration[8.1] def change - add_column :project_phases, :status, :integer, null: false, default: 0 - add_column :project_phase_journals, :status, :integer, null: false, default: 0 + 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/modules/overviews/app/components/overviews/project_phases/show_component.html.erb b/modules/overviews/app/components/overviews/project_phases/show_component.html.erb index 1434b22cf4b0..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 @@ -14,7 +14,7 @@ details_container.with_row(mb: margin) do render( Overviews::ProjectPhases::ItemComponent.new( - phase: phase, + phase:, user: @user ) ) From ad1e6a8f4b80c32da24a90529778161b7f3b891c Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Mon, 26 Oct 2026 17:28:13 +0100 Subject: [PATCH 14/19] [OP-20396] PR feedback Fixes the StatuButton component clobbering the generated classname attribute (that has the highlight class) when you also pass in a classname. Removes the extra code in the PhaseStatucButton that was getting around that. --- app/components/op_primer/status_button_component.rb | 2 +- app/components/projects/phase_status_button_component.html.erb | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/components/op_primer/status_button_component.rb b/app/components/op_primer/status_button_component.rb index c342f0684a9c..842f9c881307 100644 --- a/app/components/op_primer/status_button_component.rb +++ b/app/components/op_primer/status_button_component.rb @@ -84,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 index 9198c48881af..8e827e324821 100644 --- a/app/components/projects/phase_status_button_component.html.erb +++ b/app/components/projects/phase_status_button_component.html.erb @@ -10,7 +10,6 @@ icon_only: true, classes: class_names( "op-phase-status-button", - helpers.hl_foreground_class(:project_phase_status, @status.id) ), title: project_phase_status_name(@status.code), size: :medium, From a30f39ce872c07f7aa74cd60562b9448734be2a4 Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Tue, 27 Oct 2026 09:46:07 +0100 Subject: [PATCH 15/19] [OP-20396] PR feedback Refactors the phase update and phase-status update contracts, to pull the delegates up to the basecontract. Uses the phase status enum values in the caase statement instead of strings. --- app/contracts/project_phases/base_contract.rb | 2 ++ app/contracts/project_phases/update_contract.rb | 2 -- app/contracts/project_phases/update_status_contract.rb | 2 -- app/services/project_phases/update_status_service.rb | 8 +++++--- 4 files changed, 7 insertions(+), 7 deletions(-) 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 index ee6a3476b812..f8a03fa399bf 100644 --- a/app/contracts/project_phases/update_status_contract.rb +++ b/app/contracts/project_phases/update_status_contract.rb @@ -29,8 +29,6 @@ module ProjectPhases class UpdateStatusContract < BaseContract - delegate :project, to: :model - attribute :status end end diff --git a/app/services/project_phases/update_status_service.rb b/app/services/project_phases/update_status_service.rb index 719cbe1f4165..d1b71e2388e7 100644 --- a/app/services/project_phases/update_status_service.rb +++ b/app/services/project_phases/update_status_service.rb @@ -52,10 +52,12 @@ def cascade_to_other_phases end def cascaded_status(earlier:) + open, in_progress, completed = Project::Phase.statuses.values_at(:open, :in_progress, :completed) + case model.status - when "open" then earlier ? nil : "open" - when "in_progress" then earlier ? "completed" : "open" - when "completed" then earlier ? "completed" : nil + when open then open unless earlier + when in_progress then earlier ? completed : open + when completed then completed if earlier end end From 8cc6b1fea24ff0b1e020d74ece8932d000d40769 Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Tue, 27 Oct 2026 10:29:26 +0100 Subject: [PATCH 16/19] [OP-20396] PR feedback The status update service now makes its updates in one or two chunks (phases that come before & after), instead of looping through. Adds tests for the update-status-contract --- .../project_phases/update_status_service.rb | 29 +++-- .../update_status_contract_spec.rb | 122 ++++++++++++++++++ 2 files changed, 141 insertions(+), 10 deletions(-) create mode 100644 spec/contracts/project_phases/update_status_contract_spec.rb diff --git a/app/services/project_phases/update_status_service.rb b/app/services/project_phases/update_status_service.rb index d1b71e2388e7..0e27ba040a59 100644 --- a/app/services/project_phases/update_status_service.rb +++ b/app/services/project_phases/update_status_service.rb @@ -41,23 +41,32 @@ def after_perform(*) private def cascade_to_other_phases - project.available_phases.each do |other| - next if other == model + earlier, later = project.available_phases + .reject { it == model } + .partition { it.position < model.position } - target = cascaded_status(earlier: other.position < model.position) - next if target.nil? || other.status == target + earlier_status, later_status = cascaded_statuses.values_at(:earlier, :later) - other.update!(status: target) - end + 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_status(earlier:) + def cascaded_statuses open, in_progress, completed = Project::Phase.statuses.values_at(:open, :in_progress, :completed) case model.status - when open then open unless earlier - when in_progress then earlier ? completed : open - when completed then completed if earlier + when open then { later: open } + when in_progress then { earlier: completed, later: open } + when completed then { earlier: completed } 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 From b90e104f96f8d0f15253e0b5b8752a8f371b7ff9 Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Tue, 27 Oct 2026 11:12:08 +0100 Subject: [PATCH 17/19] [OP-20396] PR feedback Adds feature test for the phases tab on the overview page Updates tests for the button component --- .../project_phases/status_controller.rb | 4 +- .../phase_status_button_component_spec.rb | 39 +++-- .../life_cycle/overview_page/status_spec.rb | 153 ++++++++++++++++++ 3 files changed, 177 insertions(+), 19 deletions(-) create mode 100644 spec/features/projects/life_cycle/overview_page/status_spec.rb diff --git a/app/controllers/project_phases/status_controller.rb b/app/controllers/project_phases/status_controller.rb index 53cdae3684c5..41829cfcdfd8 100644 --- a/app/controllers/project_phases/status_controller.rb +++ b/app/controllers/project_phases/status_controller.rb @@ -58,9 +58,9 @@ def find_phase end def refresh_buttons - @project.available_phases.each do |phase| + @project.available_phases.reload.each do |phase| update_via_turbo_stream( - component: Projects::PhaseStatusButtonComponent.new(phase: phase.reload, user: current_user) + component: Projects::PhaseStatusButtonComponent.new(phase:, user: current_user) ) end end diff --git a/spec/components/projects/phase_status_button_component_spec.rb b/spec/components/projects/phase_status_button_component_spec.rb index 2c3d48fc3eca..569058c6a0d5 100644 --- a/spec/components/projects/phase_status_button_component_spec.rb +++ b/spec/components/projects/phase_status_button_component_spec.rb @@ -43,24 +43,31 @@ page end - shared_examples "does not render" do + shared_examples "renders nothing" do it "renders nothing" do subject - expect(rendered_content).to be_blank - expect(page).to have_no_element id: "projects-phase-status-button-component-#{phase.id}" - expect(page).to have_no_button - expect(page).to have_no_element "action-menu" + expect(rendered_content).to eq "" end end context "when the user cannot edit project phases" do - include_examples "does not render" + it_behaves_like "renders nothing" context "with a status other than the default" do let(:phase_attributes) { { status: :completed } } - include_examples "does not render" + 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 @@ -70,7 +77,7 @@ end end - include_examples "does not render" + it_behaves_like "renders nothing" end end @@ -81,19 +88,17 @@ end end - context "when the phase has no known status" do - before do - allow(Projects::PhaseStatuses).to receive(:find).with(phase.status).and_return(nil) - end - - include_examples "does not render" - end - it "renders component wrapper" do expect(subject).to have_element id: "projects-phase-status-button-component-#{phase.id}", count: 1 end - context "with the default status" do + 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 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 From 58eaf3347474776d01c31e73bb0c5c56c9d4290d Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Tue, 27 Oct 2026 11:38:47 +0100 Subject: [PATCH 18/19] [OP-20396] Linting --- .../projects/phase_status_button_component.html.erb | 4 ++-- app/controllers/project_phase_controller.rb | 0 db/migrate/20261019124852_add_status_to_project_phases.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) delete mode 100644 app/controllers/project_phase_controller.rb diff --git a/app/components/projects/phase_status_button_component.html.erb b/app/components/projects/phase_status_button_component.html.erb index 8e827e324821..8a89ba35ce1e 100644 --- a/app/components/projects/phase_status_button_component.html.erb +++ b/app/components/projects/phase_status_button_component.html.erb @@ -9,7 +9,7 @@ button_arguments: { icon_only: true, classes: class_names( - "op-phase-status-button", + "op-phase-status-button" ), title: project_phase_status_name(@status.code), size: :medium, @@ -19,7 +19,7 @@ "aria-label": project_phase_status_name(@status.code) }, menu_arguments: { - data: { test_selector: "phase-status-action-menu-#{@phase.id}" }, + data: { test_selector: "phase-status-action-menu-#{@phase.id}" } } ) ) diff --git a/app/controllers/project_phase_controller.rb b/app/controllers/project_phase_controller.rb deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/db/migrate/20261019124852_add_status_to_project_phases.rb b/db/migrate/20261019124852_add_status_to_project_phases.rb index 11d9e16c02b0..4c23f7fda57d 100644 --- a/db/migrate/20261019124852_add_status_to_project_phases.rb +++ b/db/migrate/20261019124852_add_status_to_project_phases.rb @@ -35,6 +35,6 @@ def change add_index :project_phases, :status add_check_constraint :project_phases, "status IN ('open', 'in_progress', 'completed')", - name: "project_phases_status_valid" + name: "project_phases_status_valid" end end From 4c462c6662256b2d4138e0ab3b42ab056fbded69 Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Tue, 27 Oct 2026 12:26:42 +0100 Subject: [PATCH 19/19] [OP-20396] Linting & accessibility tests --- config/locales/en.yml | 8 ++++---- spec/features/components/lookbook_accessibility_spec.rb | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index 3e86ba3e534e..a93dc11969cb 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -5960,14 +5960,14 @@ en: placeholder: "New project list" shared: "Shared project lists" phase_status: - title: - completed: 'Completed' - in_progress: 'In progress' - open: 'Open' 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/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