Repository: opf/openproject
Pull request #18852: have project phase changes show up on wp activity
Author: login: ulferts
Labels: name: needs review
Created: 2025-05-08T08:00:10Z | Merged: 2025-05-13T07:33:12Z

# Ticket

https://community.openproject.org/wp/62983

# What are you trying to accomplish?

This PR introduces functionality to journalize changes to project phase definitions associated with work packages. It uses the default state changes of journals (e.g. 'Project phase set to abc'). The difference is just in the way the values are displayed. Since project phases can both be inactive in the project as well not being configured in it at all, the formatter checks for the active state of the phase in the project. If it is inactive or not configured, "(Inactive)" is appended to the name of the definition.

## Screenshots

<img width="711" alt="image" src="https://github.com/user-attachments/assets/84453fff-75d9-4478-8f44-032dd99b0549" />

# Merge checklist

- [x] Added/updated tests



Diff:
diff --git a/app/models/journal.rb b/app/models/journal.rb
index b10edc728a70..9d93b0b875b5 100644
--- a/app/models/journal.rb
+++ b/app/models/journal.rb
@@ -58,6 +58,7 @@ class Journal < ApplicationRecord
   register_journal_formatter OpenProject::JournalFormatter::MeetingWorkPackageId
   register_journal_formatter OpenProject::JournalFormatter::ProjectPhaseActive
   register_journal_formatter OpenProject::JournalFormatter::ProjectPhaseDates
+  register_journal_formatter OpenProject::JournalFormatter::ProjectPhaseDefinition
   register_journal_formatter OpenProject::JournalFormatter::ProjectStatusCode
   register_journal_formatter OpenProject::JournalFormatter::ScheduleManually
   register_journal_formatter OpenProject::JournalFormatter::SubprojectNamedAssociation
diff --git a/app/models/work_package/journalized.rb b/app/models/work_package/journalized.rb
index 7f7b946c2490..bde600c52fce 100644
--- a/app/models/work_package/journalized.rb
+++ b/app/models/work_package/journalized.rb
@@ -100,6 +100,7 @@ def self.event_url
     register_journal_formatted_fields "ignore_non_working_days", formatter_key: :ignore_non_working_days
     register_journal_formatted_fields "cause", formatter_key: :cause
     register_journal_formatted_fields /file_links_?\d+/, formatter_key: :file_link
+    register_journal_formatted_fields "project_phase_definition_id", formatter_key: :project_phase_definition
 
     # Joined
     register_journal_formatted_fields :parent_id, :project_id,
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 9a7716c460c6..81620f7532fc 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -1673,12 +1673,13 @@ en:
       work_package: "Work packages"
     project_phase:
       activated: "activated"
-      deactivated: "deactivated"
       added_date: "set to %{date}"
       changed_date: "changed from %{from} to %{to}"
-      removed_date: "date deleted %{date}"
-      phase_and_one_gate: "%{phase_message}. %{gate_message}"
+      deactivated: "deactivated"
+      deleted_project_phase: "Deleted project phase"
       phase_and_both_gates: "%{phase_message}. %{start_gate_message}, and %{finish_gate_message}"
+      phase_and_one_gate: "%{phase_message}. %{gate_message}"
+      removed_date: "date deleted %{date}"
 
   # common attributes of all models
   attributes:
diff --git a/lib/open_project/journal_formatter/project_phase_definition.rb b/lib/open_project/journal_formatter/project_phase_definition.rb
new file mode 100644
index 000000000000..97a072ecaaf5
--- /dev/null
+++ b/lib/open_project/journal_formatter/project_phase_definition.rb
@@ -0,0 +1,52 @@
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+# ++
+
+class OpenProject::JournalFormatter::ProjectPhaseDefinition < JournalFormatter::NamedAssociation
+  private
+
+  def associated_object_name(object)
+    if object.nil?
+      I18n.t(:"activity.project_phase.deleted_project_phase")
+    elsif phase_active?(object)
+      super
+    else
+      "#{super} (#{I18n.t(:label_inactive)})"
+    end
+  end
+
+  def phase_active?(definition)
+    @journal
+      .journable
+      .project
+      .phases
+      .detect { |phase| phase.definition_id == definition.id }
+      &.active
+  end
+end
diff --git a/lib_static/plugins/acts_as_journalized/lib/journal_formatter/named_association.rb b/lib_static/plugins/acts_as_journalized/lib/journal_formatter/named_association.rb
index 2ebbcf96595c..996381df71d5 100644
--- a/lib_static/plugins/acts_as_journalized/lib/journal_formatter/named_association.rb
+++ b/lib_static/plugins/acts_as_journalized/lib/journal_formatter/named_association.rb
@@ -53,19 +53,17 @@ def format_values(values, key, cache:)
       klass = class_from_field(key)
 
       values.map do |value|
-        if klass && value
-          record = associated_object(klass, value.to_i, cache:)
-          if record
-            if record.respond_to? :name
-              record.name
-            else
-              record.subject
-            end
-          end
-        end
+        next unless klass && value
+
+        record = associated_object(klass, value.to_i, cache:)
+        associated_object_name(record)
       end
     end
 
+    def associated_object_name(object)
+      object&.name
+    end
+
     def associated_object(klass, id, cache:)
       if cache
         cache.fetch(klass, id) do
diff --git a/spec/features/work_packages/project_phases/linking_spec.rb b/spec/features/work_packages/project_phases/linking_spec.rb
index 93ca370e1edb..e9c7393bc069 100644
--- a/spec/features/work_packages/project_phases/linking_spec.rb
+++ b/spec/features/work_packages/project_phases/linking_spec.rb
@@ -50,6 +50,7 @@
   current_user { user }
 
   let(:work_package_page) { Pages::FullWorkPackage.new(work_package) }
+  let(:activity_tab) { Components::WorkPackages::Activities.new(work_package) }
 
   it "allows seeing and editing linked phases" do
     work_package_page.visit!
@@ -58,6 +59,12 @@
 
     work_package_page.set_attributes({ projectPhase: initiating_phase_definition.name })
 
+    activity_tab.within_journal_entry(work_package.journals.last) do
+      activity_tab.expect_journal_changed_attribute(
+        text: "Project phase changed from #{executing_phase_definition.name} to #{initiating_phase_definition.name}"
+      )
+    end
+
     work_package_page.expect_and_dismiss_toaster(message: "Successful update.")
 
     work_package_page.expect_attributes(project_phase: initiating_phase_definition.name)
diff --git a/spec/lib/open_project/journal_formatter/project_phase_definition_spec.rb b/spec/lib/open_project/journal_formatter/project_phase_definition_spec.rb
new file mode 100644
index 000000000000..d7e6a867fafc
--- /dev/null
+++ b/spec/lib/open_project/journal_formatter/project_phase_definition_spec.rb
@@ -0,0 +1,264 @@
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+# ++
+
+require "spec_helper"
+
+RSpec.describe OpenProject::JournalFormatter::ProjectPhaseDefinition do
+  describe "#render" do
+    let(:project_phase) { build_stubbed(:project_phase) }
+    let(:other_project_phase) { build_stubbed(:project_phase) }
+    let(:project_phases) { [project_phase, other_project_phase].compact }
+    let(:project) { build_stubbed(:project, phases: project_phases) }
+    let(:work_package) { build_stubbed(:work_package, project:) }
+    let(:journal) { build_stubbed(:work_package_journal, journable: work_package) }
+    let(:instance) { described_class.new(journal) }
+
+    before do
+      allow(Project::PhaseDefinition)
+        .to receive(:find_by)
+              .and_return(nil)
+
+      project_phases.each do |phase|
+        allow(Project::PhaseDefinition)
+          .to receive(:find_by)
+          .with(id: phase.definition_id)
+          .and_return(phase.definition)
+      end
+    end
+
+    shared_examples_for "renders project phase definition change" do
+      it { expect(instance.render(:project_phase_definition, [old_value, new_value])).to eq(expected) }
+    end
+
+    context "when setting an active phase" do
+      let(:old_value) { nil }
+      let(:new_value) { project_phase.definition_id.to_s }
+      let(:expected) do
+        I18n.t(:text_journal_set_to,
+               label: "<strong>Project phase</strong>",
+               value: "<i>#{project_phase.name}</i>")
+      end
+
+      it_behaves_like "renders project phase definition change"
+    end
+
+    context "when changing between two active phases" do
+      let(:old_value) { other_project_phase.definition_id.to_s }
+      let(:new_value) { project_phase.definition_id.to_s }
+      let(:expected) do
+        I18n.t(:text_journal_changed_plain,
+               label: "<strong>Project phase</strong>",
+               linebreak: nil,
+               old: "<i>#{other_project_phase.name}</i>",
+               new: "<i>#{project_phase.name}</i>")
+      end
+
+      it_behaves_like "renders project phase definition change"
+    end
+
+    context "when deleting an active phase" do
+      let(:old_value) { project_phase.definition_id.to_s }
+      let(:new_value) { nil }
+      let(:expected) do
+        I18n.t(:text_journal_deleted,
+               label: "<strong>Project phase</strong>",
+               old: "<strike><i>#{project_phase.name}</i></strike>")
+      end
+
+      it_behaves_like "renders project phase definition change"
+    end
+
+    context "when setting an inactive phase" do
+      let(:project_phase) { build_stubbed(:project_phase, active: false) }
+      let(:old_value) { nil }
+      let(:new_value) { project_phase.definition_id.to_s }
+      let(:expected) do
+        I18n.t(:text_journal_set_to,
+               label: "<strong>Project phase</strong>",
+               value: "<i>#{project_phase.name} (Inactive)</i>")
+      end
+
+      it_behaves_like "renders project phase definition change"
+    end
+
+    context "when changing between two inactive phases" do
+      let(:project_phase) { build_stubbed(:project_phase, active: false) }
+      let(:other_project_phase) { build_stubbed(:project_phase, active: false) }
+      let(:old_value) { other_project_phase.definition_id.to_s }
+      let(:new_value) { project_phase.definition_id.to_s }
+      let(:expected) do
+        I18n.t(:text_journal_changed_plain,
+               label: "<strong>Project phase</strong>",
+               linebreak: nil,
+               old: "<i>#{other_project_phase.name} (Inactive)</i>",
+               new: "<i>#{project_phase.name} (Inactive)</i>")
+      end
+
+      it_behaves_like "renders project phase definition change"
+    end
+
+    context "when deleting an inactive phase" do
+      let(:project_phase) { build_stubbed(:project_phase, active: false) }
+      let(:old_value) { project_phase.definition_id.to_s }
+      let(:new_value) { nil }
+      let(:expected) do
+        I18n.t(:text_journal_deleted,
+               label: "<strong>Project phase</strong>",
+               old: "<strike><i>#{project_phase.name} (Inactive)</i></strike>")
+      end
+
+      it_behaves_like "renders project phase definition change"
+    end
+
+    context "when setting a phase not configured for the project" do
+      let(:project) { build_stubbed(:project, phases: []) }
+      let(:old_value) { nil }
+      let(:new_value) { project_phase.definition_id.to_s }
+      let(:expected) do
+        I18n.t(:text_journal_set_to,
+               label: "<strong>Project phase</strong>",
+               value: "<i>#{project_phase.name} (Inactive)</i>")
+      end
+
+      it_behaves_like "renders project phase definition change"
+    end
+
+    context "when changing between two phases not configured for the project" do
+      let(:project) { build_stubbed(:project, phases: []) }
+      let(:old_value) { other_project_phase.definition_id.to_s }
+      let(:new_value) { project_phase.definition_id.to_s }
+      let(:expected) do
+        I18n.t(:text_journal_changed_plain,
+               label: "<strong>Project phase</strong>",
+               linebreak: nil,
+               old: "<i>#{other_project_phase.name} (Inactive)</i>",
+               new: "<i>#{project_phase.name} (Inactive)</i>")
+      end
+
+      it_behaves_like "renders project phase definition change"
+    end
+
+    context "when deleting a phase not configured for the project" do
+      let(:project) { build_stubbed(:project, phases: []) }
+      let(:old_value) { project_phase.definition_id.to_s }
+      let(:new_value) { nil }
+      let(:expected) do
+        I18n.t(:text_journal_deleted,
+               label: "<strong>Project phase</strong>",
+               old: "<strike><i>#{project_phase.name} (Inactive)</i></strike>")
+      end
+
+      it_behaves_like "renders project phase definition change"
+    end
+
+    context "when setting a phase whose definition is deleted" do
+      let(:old_value) { nil }
+      let(:new_value) { "-1" }
+      let(:expected) do
+        I18n.t(:text_journal_set_to,
+               label: "<strong>Project phase</strong>",
+               value: "<i>#{I18n.t(:"activity.project_phase.deleted_project_phase")}</i>")
+      end
+
+      it_behaves_like "renders project phase definition change"
+    end
+
+    context "when changing between two phases whose definition is deleted" do
+      let(:old_value) { "-1" }
+      let(:new_value) { "-2" }
+      let(:expected) do
+        I18n.t(:text_journal_changed_plain,
+               label: "<strong>Project phase</strong>",
+               linebreak: nil,
+               old: "<i>#{I18n.t(:"activity.project_phase.deleted_project_phase")}</i>",
+               new: "<i>#{I18n.t(:"activity.project_phase.deleted_project_phase")}</i>")
+      end
+
+      it_behaves_like "renders project phase definition change"
+    end
+
+    context "when deleting a phase whose definition is deleted" do
+      let(:old_value) { "-1" }
+      let(:new_value) { nil }
+      let(:expected) do
+        I18n.t(:text_journal_deleted,
+               label: "<strong>Project phase</strong>",
+               old: "<strike><i>#{I18n.t(:"activity.project_phase.deleted_project_phase")}</i></strike>")
+      end
+
+      it_behaves_like "renders project phase definition change"
+    end
+
+    context "when changing between an active and an inactive phase" do
+      let(:project_phase) { build_stubbed(:project_phase, active: true) }
+      let(:other_project_phase) { build_stubbed(:project_phase, active: false) }
+      let(:old_value) { other_project_phase.definition_id.to_s }
+      let(:new_value) { project_phase.definition_id.to_s }
+      let(:expected) do
+        I18n.t(:text_journal_changed_plain,
+               label: "<strong>Project phase</strong>",
+               linebreak: nil,
+               old: "<i>#{other_project_phase.name} (Inactive)</i>",
+               new: "<i>#{project_phase.name}</i>")
+      end
+
+      it_behaves_like "renders project phase definition change"
+    end
+
+    context "when changing between an active and a phase not configured in the project" do
+      let(:project) { build_stubbed(:project, phases: [other_project_phase]) }
+      let(:old_value) { other_project_phase.definition_id.to_s }
+      let(:new_value) { project_phase.definition_id.to_s }
+      let(:expected) do
+        I18n.t(:text_journal_changed_plain,
+               label: "<strong>Project phase</strong>",
+               linebreak: nil,
+               old: "<i>#{other_project_phase.name}</i>",
+               new: "<i>#{project_phase.name} (Inactive)</i>")
+      end
+
+      it_behaves_like "renders project phase definition change"
+    end
+
+    context "when changing between an active and a deleted phase" do
+      let(:old_value) { other_project_phase.definition_id.to_s }
+      let(:new_value) { "-1" }
+      let(:expected) do
+        I18n.t(:text_journal_changed_plain,
+               label: "<strong>Project phase</strong>",
+               linebreak: nil,
+               old: "<i>#{other_project_phase.name}</i>",
+               new: "<i>#{I18n.t(:"activity.project_phase.deleted_project_phase")}</i>")
+      end
+
+      it_behaves_like "renders project phase definition change"
+    end
+  end
+end


Reviews:
user: login: ulferts
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T08:02:11Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR introduces functionality to display project phase changes in work package journal entries with a special formatting for inactive or unconfigured phases.  
- Adds a journal formatter that appends an inactive label for project phases that are inactive or not configured.  
- Updates tests and feature specs to cover these new formatted outputs.  
- Registers the new formatter in the Journal and WorkPackage models.

### Reviewed Changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| spec/lib/open_project/journal_formatter/project_phase_definition_spec.rb | Adds tests verifying proper rendering of phase definitions for both active and inactive states. |
| spec/features/work_packages/project_phases/linking_spec.rb | Updates UI tests to assert that the journal entry displays the correct change message. |
| lib_static/plugins/acts_as_journalized/lib/journal_formatter/named_association.rb | Adjusts the handling of nil values when formatting associated object names. |
| lib/open_project/journal_formatter/project_phase_definition.rb | Implements the formatter that adds an inactive label based on project phase activity. |
| app/models/work_package/journalized.rb | Registers the new journal formatter field for project phase definitions. |
| app/models/journal.rb | Registers the new journal formatter to be used for project phase definition changes. |
</details>



<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**lib/open_project/journal_formatter/project_phase_definition.rb:35**
* Ensure that I18n.t(:label_inactive) returns exactly 'Inactive' to match test expectations, as the spec uses a hardcoded '(Inactive)' string for validating inactive project phases.
```
if phase_active?(object)
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T16:00:17Z
user: login: toy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T16:56:37Z
user: login: ulferts
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T06:53:12Z
user: login: ulferts
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T06:57:06Z
user: login: toy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T09:38:43Z
user: login: toy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T09:57:34Z
user: login: toy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T10:06:55Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR makes project phase changes visible in WP activity journals by introducing a specialized journal formatter for project phase definitions. Key changes include new tests covering various phase state transitions, updates to feature specs to verify journal entries, and the implementation of the new formatter in production code.

### Reviewed Changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.

<details>
<summary>Show a summary per file</summary>

| File                                                                                  | Description                                                        |
|---------------------------------------------------------------------------------------|--------------------------------------------------------------------|
| spec/lib/open_project/journal_formatter/project_phase_definition_spec.rb              | Adds comprehensive unit tests for journal formatting of phase changes. |
| spec/features/work_packages/project_phases/linking_spec.rb                            | Enhances feature tests to validate phase linkage and journal display.    |
| lib_static/plugins/acts_as_journalized/lib/journal_formatter/named_association.rb        | Refactors value formatting for associated objects, removing previous fallback behavior.  |
| lib/open_project/journal_formatter/project_phase_definition.rb                        | Implements the formatter that displays phase names and inactive state. |
| config/locales/en.yml                                                                  | Updates locale keys for project phase journaling messages.         |
| app/models/work_package/journalized.rb, app/models/journal.rb                           | Registers the new journal formatter for project phase definitions.   |
</details>



<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**lib_static/plugins/acts_as_journalized/lib/journal_formatter/named_association.rb:55**
* Previously, a fallback returned record.subject when the record did not respond to :name; ensure that dropping this fallback and relying solely on object&.name is intentional.
```
record = associated_object(klass, value.to_i, cache:)
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T12:10:34Z
user: login: toy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T12:17:55Z
user: login: toy
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T12:33:21Z

Review comments:
diff_hunk: @@ -53,19 +53,17 @@ def format_values(values, key, cache:)
       klass = class_from_field(key)
 
       values.map do |value|
-        if klass && value
-          record = associated_object(klass, value.to_i, cache:)
-          if record
-            if record.respond_to? :name
-              record.name
-            else
-              record.subject
path: lib_static/plugins/acts_as_journalized/lib/journal_formatter/named_association.rb
user: login: ulferts
body: This extra path should no longer be necessary as [work packages have a name method](https://github.com/opf/openproject/blob/dev/app/models/work_package.rb#L343-L345) implemented by now
created_at: 2025-05-08T08:02:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -53,19 +53,17 @@ def format_values(values, key, cache:)
       klass = class_from_field(key)
 
       values.map do |value|
path: lib_static/plugins/acts_as_journalized/lib/journal_formatter/named_association.rb
user: login: Copilot
body: [nitpick] Consider adding a comment or logging for the case when 'klass' or 'value' is nil to clarify that skipping processing these entries is intentional.
```suggestion
      values.map do |value|
        # Skip processing if 'klass' or 'value' is nil. This is intentional to avoid
        # handling invalid or incomplete data.
```
created_at: 2025-05-08T16:00:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,47 @@
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+# ++
+
+class OpenProject::JournalFormatter::ProjectPhaseDefinition < JournalFormatter::NamedAssociation
+  private
+
+  def associated_object_name(object)
+    if phase_active?(object)
+      super
+    else
+      "#{super} (#{I18n.t(:label_inactive)})"
+    end
+  end
+
+  def phase_active?(definition)
+    phase = @journal.journable.project.phases.detect { |phase| phase.definition_id == definition.id } if definition
path: lib/open_project/journal_formatter/project_phase_definition.rb
user: login: toy
body: Why load all phases and not find needed phase by `definition_id`?
created_at: 2025-05-08T16:41:34Z
author_association: CONTRIBUTOR
path: spec/lib/open_project/journal_formatter/project_phase_definition_spec.rb
user: login: toy
body: Maybe better to add a test that checks changing from active to inactive and/or vice versa
created_at: 2025-05-08T16:56:22Z
author_association: CONTRIBUTOR
path: spec/lib/open_project/journal_formatter/project_phase_definition_spec.rb
user: login: ulferts
body: I would trust the unit specs to test that. The feature specs ensure that the journal formatter is hooked up and the details can then be enforced by the unit spec.
created_at: 2025-05-09T06:53:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,47 @@
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+# ++
+
+class OpenProject::JournalFormatter::ProjectPhaseDefinition < JournalFormatter::NamedAssociation
+  private
+
+  def associated_object_name(object)
+    if phase_active?(object)
+      super
+    else
+      "#{super} (#{I18n.t(:label_inactive)})"
+    end
+  end
+
+  def phase_active?(definition)
+    phase = @journal.journable.project.phases.detect { |phase| phase.definition_id == definition.id } if definition
path: lib/open_project/journal_formatter/project_phase_definition.rb
user: login: ulferts
body: It is a bet. While fetching that one specific phase via a find_by directly would definitely be faster for a single journal, I wager that the performance will increase if the phase is changed more than once. Since the project is shared across journals, so are the phases. By that, journals can access them again without taking a performance hit. 
created_at: 2025-05-09T06:57:06Z
author_association: CONTRIBUTOR
path: spec/lib/open_project/journal_formatter/project_phase_definition_spec.rb
user: login: toy
body: I am suggesting to add a unit test, there are already 9 cases covered, but none for phases being in different active state.
created_at: 2025-05-09T09:38:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,47 @@
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+# ++
+
+class OpenProject::JournalFormatter::ProjectPhaseDefinition < JournalFormatter::NamedAssociation
+  private
+
+  def associated_object_name(object)
+    if phase_active?(object)
+      super
+    else
+      "#{super} (#{I18n.t(:label_inactive)})"
+    end
+  end
+
+  def phase_active?(definition)
+    phase = @journal.journable.project.phases.detect { |phase| phase.definition_id == definition.id } if definition
path: lib/open_project/journal_formatter/project_phase_definition.rb
user: login: toy
body: Ok, let it be
created_at: 2025-05-09T09:57:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,47 @@
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+# ++
+
+class OpenProject::JournalFormatter::ProjectPhaseDefinition < JournalFormatter::NamedAssociation
+  private
+
+  def associated_object_name(object)
+    if phase_active?(object)
+      super
+    else
+      "#{super} (#{I18n.t(:label_inactive)})"
+    end
+  end
+
+  def phase_active?(definition)
+    phase = @journal.journable.project.phases.detect { |phase| phase.definition_id == definition.id } if definition
+
+    phase&.active
path: lib/open_project/journal_formatter/project_phase_definition.rb
user: login: toy
body: Maybe then:
```suggestion
    return unless definition
    
    @journal.journable.project.phases
      .find { it.definition_id == definition.id }
      &.active?
```

or

```suggestion
    return unless definition
    
    @journal.journable.project.phases
      .find { |phase| phase.definition_id == definition.id }
      &.active?
```

created_at: 2025-05-09T10:06:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,264 @@
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+# ++
+
+require "spec_helper"
+
+RSpec.describe OpenProject::JournalFormatter::ProjectPhaseDefinition do
+  describe "#render" do
+    let(:project_phase) { build_stubbed(:project_phase) }
+    let(:other_project_phase) { build_stubbed(:project_phase) }
+    let(:project_phases) { [project_phase, other_project_phase].compact }
+    let(:project) { build_stubbed(:project, phases: project_phases) }
+    let(:work_package) { build_stubbed(:work_package, project:) }
+    let(:journal) { build_stubbed(:work_package_journal, journable: work_package) }
+    let(:instance) { described_class.new(journal) }
+
+    before do
+      allow(Project::PhaseDefinition)
+        .to receive(:find_by)
+              .and_return(nil)
+
+      project_phases.each do |phase|
+        allow(Project::PhaseDefinition)
+          .to receive(:find_by)
+          .with(id: phase.definition_id)
+          .and_return(phase.definition)
+      end
+    end
+
+    shared_examples_for "renders project phase definition change" do
+      it { expect(instance.render(:project_phase_definition, [old_value, new_value])).to eq(expected) }
+    end
+
+    context "when setting an active phase" do
+      let(:old_value) { nil }
+      let(:new_value) { project_phase.definition_id.to_s }
+      let(:expected) do
+        I18n.t(:text_journal_set_to,
+               label: "<strong>Project phase</strong>",
+               value: "<i>#{project_phase.name}</i>")
+      end
+
+      it_behaves_like "renders project phase definition change"
+    end
+
+    context "when changing between two active phases" do
+      let(:old_value) { other_project_phase.definition_id.to_s }
+      let(:new_value) { project_phase.definition_id.to_s }
+      let(:expected) do
+        I18n.t(:text_journal_changed_plain,
+               label: "<strong>Project phase</strong>",
+               linebreak: nil,
+               old: "<i>#{other_project_phase.name}</i>",
+               new: "<i>#{project_phase.name}</i>")
+      end
+
+      it_behaves_like "renders project phase definition change"
+    end
+
+    context "when deleting an active phase" do
+      let(:old_value) { project_phase.definition_id.to_s }
+      let(:new_value) { nil }
+      let(:expected) do
+        I18n.t(:text_journal_deleted,
+               label: "<strong>Project phase</strong>",
+               old: "<strike><i>#{project_phase.name}</i></strike>")
+      end
+
+      it_behaves_like "renders project phase definition change"
+    end
+
+    context "when setting an inactive phase" do
+      let(:project_phase) { build_stubbed(:project_phase, active: false) }
+      let(:old_value) { nil }
+      let(:new_value) { project_phase.definition_id.to_s }
+      let(:expected) do
+        I18n.t(:text_journal_set_to,
+               label: "<strong>Project phase</strong>",
+               value: "<i>#{project_phase.name} (Inactive)</i>")
+      end
+
+      it_behaves_like "renders project phase definition change"
+    end
+
+    context "when changing between two inactive phases" do
+      let(:project_phase) { build_stubbed(:project_phase, active: false) }
+      let(:other_project_phase) { build_stubbed(:project_phase, active: false) }
+      let(:old_value) { other_project_phase.definition_id.to_s }
+      let(:new_value) { project_phase.definition_id.to_s }
+      let(:expected) do
+        I18n.t(:text_journal_changed_plain,
+               label: "<strong>Project phase</strong>",
+               linebreak: nil,
+               old: "<i>#{other_project_phase.name} (Inactive)</i>",
+               new: "<i>#{project_phase.name} (Inactive)</i>")
+      end
+
+      it_behaves_like "renders project phase definition change"
+    end
+
+    context "when deleting an inactive phase" do
+      let(:project_phase) { build_stubbed(:project_phase, active: false) }
+      let(:old_value) { project_phase.definition_id.to_s }
+      let(:new_value) { nil }
+      let(:expected) do
+        I18n.t(:text_journal_deleted,
+               label: "<strong>Project phase</strong>",
+               old: "<strike><i>#{project_phase.name} (Inactive)</i></strike>")
+      end
+
+      it_behaves_like "renders project phase definition change"
+    end
+
+    context "when setting a phase not configured for the project" do
+      let(:project) { build_stubbed(:project, phases: []) }
+      let(:old_value) { nil }
+      let(:new_value) { project_phase.definition_id.to_s }
+      let(:expected) do
+        I18n.t(:text_journal_set_to,
+               label: "<strong>Project phase</strong>",
+               value: "<i>#{project_phase.name} (Inactive)</i>")
+      end
+
+      it_behaves_like "renders project phase definition change"
+    end
+
+    context "when changing between two phases not configured for the project" do
+      let(:project) { build_stubbed(:project, phases: []) }
+      let(:old_value) { other_project_phase.definition_id.to_s }
+      let(:new_value) { project_phase.definition_id.to_s }
+      let(:expected) do
+        I18n.t(:text_journal_changed_plain,
+               label: "<strong>Project phase</strong>",
+               linebreak: nil,
+               old: "<i>#{other_project_phase.name} (Inactive)</i>",
+               new: "<i>#{project_phase.name} (Inactive)</i>")
+      end
+
+      it_behaves_like "renders project phase definition change"
+    end
+
+    context "when deleting a phase not configured for the project" do
+      let(:project) { build_stubbed(:project, phases: []) }
+      let(:old_value) { project_phase.definition_id.to_s }
+      let(:new_value) { nil }
+      let(:expected) do
+        I18n.t(:text_journal_deleted,
+               label: "<strong>Project phase</strong>",
+               old: "<strike><i>#{project_phase.name} (Inactive)</i></strike>")
+      end
+
+      it_behaves_like "renders project phase definition change"
+    end
+
+    context "when setting a phase whose definition is deleted" do
+      let(:old_value) { nil }
+      let(:new_value) { -1.to_s }
path: spec/lib/open_project/journal_formatter/project_phase_definition_spec.rb
user: login: toy
body: I'd expect rubocop to annoy about this:
```suggestion
      let(:new_value) { "-1" }
```
created_at: 2025-05-12T12:17:54Z
author_association: CONTRIBUTOR

PR comments:
user: login: toy
created_at: 2025-05-08T16:38:52Z
body: Not a problem of this PR, but it feels odd that if a work package is assigned to inactive phase, then from looking at activity it is assigned, but when looking at the work package, it is not
author_association: CONTRIBUTOR
user: login: toy
created_at: 2025-05-09T12:27:37Z
body: If definition gets deleted, it will be displayed as just `(Inactive)`, probably better if it says that it was a deleted phase
author_association: CONTRIBUTOR
user: login: ulferts
created_at: 2025-05-12T11:32:04Z
body: > If definition gets deleted, it will be displayed as just `(Inactive)`, probably better if it says that it was a deleted phase

Well spotted. I added a special return value for this case in the formatter. That change also made the handling of a nil object in `#phase_active?` superfluous.
author_association: CONTRIBUTOR