diff --git a/app/models/journable/with_historic_attributes.rb b/app/models/journable/with_historic_attributes.rb
index 088fad697f2e..48b190518e31 100644
--- a/app/models/journable/with_historic_attributes.rb
+++ b/app/models/journable/with_historic_attributes.rb
@@ -264,6 +264,7 @@ def changes_at_timestamp(timestamp)
merge_target_versions_changes!(changes, historic_journable)
merge_observed_in_versions_changes!(changes, historic_journable)
+ merge_labels_changes!(changes, historic_journable)
changes
end
@@ -276,12 +277,12 @@ def merge_target_versions_changes!(changes, historic_journable)
end
def target_versions_changes(historic_journable)
- old_ids = sorted_version_ids(historic_journable, :target_versions)
- new_ids = sorted_version_ids(__getobj__, :target_versions)
+ old_ids = sorted_association_ids(historic_journable, :target_versions)
+ new_ids = sorted_association_ids(__getobj__, :target_versions)
{}.tap do |changes|
changes["version_id"] = [old_ids.first, new_ids.first] if old_ids.first != new_ids.first
- changes["target_versions"] = [old_ids, new_ids].map { joined_version_ids(it) } if old_ids != new_ids
+ changes["target_versions"] = [old_ids, new_ids].map { joined_ids(it) } if old_ids != new_ids
end
end
@@ -292,19 +293,34 @@ def merge_observed_in_versions_changes!(changes, historic_journable)
end
def observed_in_versions_changes(historic_journable)
- old_ids = sorted_version_ids(historic_journable, :observed_in_versions)
- new_ids = sorted_version_ids(__getobj__, :observed_in_versions)
+ old_ids = sorted_association_ids(historic_journable, :observed_in_versions)
+ new_ids = sorted_association_ids(__getobj__, :observed_in_versions)
{}.tap do |changes|
- changes["observed_in_versions"] = [old_ids, new_ids].map { joined_version_ids(it) } if old_ids != new_ids
+ changes["observed_in_versions"] = [old_ids, new_ids].map { joined_ids(it) } if old_ids != new_ids
end
end
- def sorted_version_ids(work_package, association)
+ def merge_labels_changes!(changes, historic_journable)
+ return unless __getobj__.respond_to?(:labels)
+
+ changes.merge!(labels_changes(historic_journable))
+ end
+
+ def labels_changes(historic_journable)
+ old_ids = sorted_association_ids(historic_journable, :labels)
+ new_ids = sorted_association_ids(__getobj__, :labels)
+
+ {}.tap do |changes|
+ changes["labels"] = [old_ids, new_ids].map { joined_ids(it) } if old_ids != new_ids
+ end
+ end
+
+ def sorted_association_ids(work_package, association)
work_package.public_send(association).map(&:id).sort
end
- def joined_version_ids(ids)
+ def joined_ids(ids)
ids.join(",").presence
end
diff --git a/app/models/journable/with_historic_attributes/loader.rb b/app/models/journable/with_historic_attributes/loader.rb
index 67c527051971..fa0d474a3259 100644
--- a/app/models/journable/with_historic_attributes/loader.rb
+++ b/app/models/journable/with_historic_attributes/loader.rb
@@ -69,6 +69,7 @@ def load_journal_associations(journalized = journables)
load_custom_value_associations(journalized, journal_ids)
load_versions_associations(journalized, journal_ids, kind: "target", association: :target_versions)
load_versions_associations(journalized, journal_ids, kind: "observed_in", association: :observed_in_versions)
+ load_labels_association(journalized, journal_ids)
journalized
end
@@ -91,7 +92,20 @@ def load_versions_associations(journalized, journal_ids, kind:, association:)
journalized.each do |work_package|
version_journals = Array(version_journals_by_journal_id[work_package.journal_id])
- set_versions_association_from_journal!(work_package:, version_journals:, association:)
+ historic_versions = version_journals.filter_map(&:version).sort_by(&:id)
+ set_association_from_journal!(work_package:, records: historic_versions, association:)
+ end
+ end
+
+ def load_labels_association(journalized, journal_ids)
+ return unless journalized_class.method_defined?(:labels)
+
+ label_journals_by_journal_id = load_label_journals_by_journal_id(journal_ids)
+
+ journalized.each do |work_package|
+ label_journals = Array(label_journals_by_journal_id[work_package.journal_id])
+ historic_labels = label_journals.filter_map(&:label).sort_by(&:id)
+ set_association_from_journal!(work_package:, records: historic_labels, association: :labels)
end
end
@@ -144,6 +158,13 @@ def load_version_journals_by_journal_id(journal_ids, kind:)
.group_by(&:journal_id)
end
+ def load_label_journals_by_journal_id(journal_ids)
+ Journal::LabelJournal
+ .where(journal_id: journal_ids)
+ .includes(:label)
+ .group_by(&:journal_id)
+ end
+
def set_custom_value_association_from_journal!(work_package:, customizable_journals:)
# Build the associated customizable_journals as custom values, this way the historic work packages
# will behave just as the normal ones. Additionally set the reverse customized association
@@ -156,11 +177,9 @@ def set_custom_value_association_from_journal!(work_package:, customizable_journ
work_package.association(:custom_values).target = historic_custom_values
end
- def set_versions_association_from_journal!(work_package:, version_journals:, association:)
- historic_versions = version_journals.filter_map(&:version).sort_by(&:id)
-
+ def set_association_from_journal!(work_package:, records:, association:)
work_package.association(association).loaded!
- work_package.association(association).target = historic_versions
+ work_package.association(association).target = records
end
attr_accessor :journables
diff --git a/app/models/journal.rb b/app/models/journal.rb
index 0e7f516832e3..73a5992ec1b8 100644
--- a/app/models/journal.rb
+++ b/app/models/journal.rb
@@ -57,6 +57,7 @@ class Journal < ApplicationRecord
register_journal_formatter OpenProject::JournalFormatter::Diff
register_journal_formatter OpenProject::JournalFormatter::FileLink
register_journal_formatter OpenProject::JournalFormatter::IgnoreNonWorkingDays
+ register_journal_formatter OpenProject::JournalFormatter::Labels
register_journal_formatter OpenProject::JournalFormatter::MeetingStartTime
register_journal_formatter OpenProject::JournalFormatter::MeetingState
register_journal_formatter OpenProject::JournalFormatter::MeetingWorkPackageId
@@ -133,6 +134,7 @@ class Journal < ApplicationRecord
has_many :custom_comment_journals, class_name: "Journal::CustomCommentJournal", dependent: :delete_all
has_many :project_phase_journals, class_name: "Journal::ProjectPhaseJournal", dependent: :delete_all
has_many :storable_journals, class_name: "Journal::StorableJournal", dependent: :delete_all
+ has_many :label_journals, class_name: "Journal::LabelJournal", dependent: :delete_all
has_many :work_package_version_journals, class_name: "Journal::WorkPackageVersionJournal", dependent: :delete_all
# Row lifecycle is owned by work_package_version_journals above.
# rubocop:disable Rails/HasManyOrHasOneDependent
diff --git a/app/models/journal/label_journal.rb b/app/models/journal/label_journal.rb
new file mode 100644
index 000000000000..f83523b30bb7
--- /dev/null
+++ b/app/models/journal/label_journal.rb
@@ -0,0 +1,35 @@
+# 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 Journal::LabelJournal < Journal::AssociatedJournal
+ self.table_name = "label_journals"
+
+ belongs_to :label
+end
diff --git a/app/models/work_package/journalized.rb b/app/models/work_package/journalized.rb
index 99d7b0e4ccf3..86ffc7a71554 100644
--- a/app/models/work_package/journalized.rb
+++ b/app/models/work_package/journalized.rb
@@ -107,6 +107,7 @@ def self.event_url
register_journal_formatted_fields "project_phase_definition_id", formatter_key: :project_phase_definition
register_journal_formatted_fields "target_versions", formatter_key: :target_versions
register_journal_formatted_fields "observed_in_versions", formatter_key: :observed_in_versions
+ register_journal_formatted_fields "labels", formatter_key: :labels
# Joined
register_journal_formatted_fields :parent_id, :project_id,
diff --git a/app/services/journals/create_service/association.rb b/app/services/journals/create_service/association.rb
index 60c431eabafd..a3c7fab0e141 100644
--- a/app/services/journals/create_service/association.rb
+++ b/app/services/journals/create_service/association.rb
@@ -34,7 +34,7 @@ class Association
# Core associations are defined here. Module-specific associations can be defined in engines
# using `Journals::CreateService::Association.register`.
- @registry = Set.new(%i[Attachable CustomComment Customizable ProjectPhase WorkPackageVersion])
+ @registry = Set.new(%i[Attachable CustomComment Customizable Labelable ProjectPhase WorkPackageVersion])
class << self
def register(*names)
diff --git a/app/services/journals/create_service/labelable.rb b/app/services/journals/create_service/labelable.rb
new file mode 100644
index 000000000000..60fa1e70badb
--- /dev/null
+++ b/app/services/journals/create_service/labelable.rb
@@ -0,0 +1,86 @@
+# 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 Journals::CreateService
+ class Labelable < Association
+ def associated?
+ journable.respond_to?(:labels)
+ end
+
+ def cleanup_predecessor(predecessor, notes, cause)
+ cleanup_predecessor_for(predecessor,
+ notes,
+ cause,
+ "label_journals",
+ :journal_id,
+ :id)
+ end
+
+ def insert_sql
+ sanitize(<<~SQL.squish, journable_id:, labelable_type: journable_class_name)
+ INSERT INTO
+ label_journals (
+ journal_id,
+ label_id
+ )
+ SELECT
+ #{id_from_inserted_journal_sql},
+ labelings.label_id
+ FROM labelings
+ WHERE
+ #{only_if_created_sql}
+ AND labelings.labelable_type = :labelable_type
+ AND labelings.labelable_id = :journable_id
+ SQL
+ end
+
+ def changes_sql
+ sanitize(<<~SQL.squish, journable_id:, labelable_type: journable_class_name)
+ SELECT
+ :journable_id AS journable_id
+ FROM
+ (
+ SELECT ARRAY_AGG(label_id ORDER BY label_id) AS labels
+ FROM labelings
+ WHERE labelings.labelable_type = :labelable_type
+ AND labelings.labelable_id = :journable_id
+ ) current_labels
+ CROSS JOIN
+ (
+ SELECT ARRAY_AGG(label_id ORDER BY label_id) AS labels
+ FROM label_journals
+ WHERE journal_id IN (SELECT id FROM max_journals)
+ ) journal_labels
+ WHERE
+ current_labels.labels IS DISTINCT FROM journal_labels.labels
+ SQL
+ end
+ end
+end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 4d6063106457..7ac448863e52 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -509,6 +509,7 @@ en:
title: "Working days"
true: "include non-working days"
journal_internal: Internal Journal
+ labels: "Labels"
notify: "Notify" # used in custom actions
observed_in_versions: "Observed in versions"
parent: "Parent"
@@ -1163,6 +1164,9 @@ en:
issue_priority:
one: "Priority"
other: "Priorities"
+ label:
+ one: "Label"
+ other: "Labels"
ldap_auth_source:
one: "LDAP connection"
other: "LDAP connections"
diff --git a/db/migrate/20260914130000_create_label_journals.rb b/db/migrate/20260914130000_create_label_journals.rb
new file mode 100644
index 000000000000..8af04d0e0b16
--- /dev/null
+++ b/db/migrate/20260914130000_create_label_journals.rb
@@ -0,0 +1,39 @@
+# 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 CreateLabelJournals < ActiveRecord::Migration[8.1]
+ def change
+ create_table :label_journals do |t| # rubocop:disable Rails/CreateTableWithTimestamps
+ t.belongs_to :journal, null: false, foreign_key: true
+ # No foreign key: journal snapshots must outlive deleted labels.
+ t.belongs_to :label, null: false
+ end
+ end
+end
diff --git a/lib/open_project/journal_formatter/joined_versions.rb b/lib/open_project/journal_formatter/joined_association.rb
similarity index 80%
rename from lib/open_project/journal_formatter/joined_versions.rb
rename to lib/open_project/journal_formatter/joined_association.rb
index fe63bb144784..76d7f20c620d 100644
--- a/lib/open_project/journal_formatter/joined_versions.rb
+++ b/lib/open_project/journal_formatter/joined_association.rb
@@ -28,11 +28,10 @@
# See COPYRIGHT and LICENSE files for more details.
#++
-# Base for formatters rendering the change to a set of versions referenced by a
-# work package. Each value is the sorted, comma-joined version ids (see
-# JournalChanges); every id is resolved to the version's name, dropping
-# versions that have been deleted or are no longer visible to the reader.
-class OpenProject::JournalFormatter::JoinedVersions < JournalFormatter::NamedAssociation
+# Base for formatters rendering the change to a set of associated records
+# referenced by the journable. Each value is the sorted, comma-joined ids;
+# deleted or non-visible records are dropped.
+class OpenProject::JournalFormatter::JoinedAssociation < JournalFormatter::NamedAssociation
private
def format_values(values, key)
diff --git a/lib/open_project/journal_formatter/labels.rb b/lib/open_project/journal_formatter/labels.rb
new file mode 100644
index 000000000000..4603302f137f
--- /dev/null
+++ b/lib/open_project/journal_formatter/labels.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, 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.
+#++
+
+# Renders the change to the set of labels attached to a work package
+# (see JournalChanges#get_labels_changes).
+class OpenProject::JournalFormatter::Labels < OpenProject::JournalFormatter::JoinedAssociation
+end
diff --git a/lib/open_project/journal_formatter/observed_in_versions.rb b/lib/open_project/journal_formatter/observed_in_versions.rb
index f99881cf4ae8..ebfd3d71065c 100644
--- a/lib/open_project/journal_formatter/observed_in_versions.rb
+++ b/lib/open_project/journal_formatter/observed_in_versions.rb
@@ -30,5 +30,5 @@
# Renders the change to the set of versions a work package was observed in
# (see JournalChanges#get_observed_in_versions_changes).
-class OpenProject::JournalFormatter::ObservedInVersions < OpenProject::JournalFormatter::JoinedVersions
+class OpenProject::JournalFormatter::ObservedInVersions < OpenProject::JournalFormatter::JoinedAssociation
end
diff --git a/lib/open_project/journal_formatter/target_versions.rb b/lib/open_project/journal_formatter/target_versions.rb
index 38a552689e6a..59b01d6a35ed 100644
--- a/lib/open_project/journal_formatter/target_versions.rb
+++ b/lib/open_project/journal_formatter/target_versions.rb
@@ -30,7 +30,7 @@
# Renders the change to the set of target versions
# (see JournalChanges#get_target_versions_changes).
-class OpenProject::JournalFormatter::TargetVersions < OpenProject::JournalFormatter::JoinedVersions
+class OpenProject::JournalFormatter::TargetVersions < OpenProject::JournalFormatter::JoinedAssociation
private
# While the multiple versions feature is inactive, the rest of the UI still
diff --git a/lib_static/plugins/acts_as_journalized/lib/journal_changes.rb b/lib_static/plugins/acts_as_journalized/lib/journal_changes.rb
index fd4c7cd6d46c..90225eff0457 100644
--- a/lib_static/plugins/acts_as_journalized/lib/journal_changes.rb
+++ b/lib_static/plugins/acts_as_journalized/lib/journal_changes.rb
@@ -48,6 +48,7 @@ def all_changes
get_project_phases_changes,
get_target_versions_changes,
get_observed_in_versions_changes,
+ get_labels_changes,
get_file_links_changes,
get_participants_changes,
get_agenda_items_changes
@@ -156,6 +157,16 @@ def get_observed_in_versions_changes
{ observed_in_versions: [old_value, new_value] }
end
+ def get_labels_changes
+ return unless journable.respond_to?(:labels)
+
+ old_value = predecessor && joined_label_ids(predecessor)
+ new_value = joined_label_ids(self)
+ return if old_value == new_value
+
+ { labels: [old_value, new_value] }
+ end
+
def get_file_links_changes
return unless has_file_links?
@@ -217,6 +228,10 @@ def joined_observed_in_version_ids(journal)
journal.observed_in_version_journals.map(&:version_id).sort.join(",").presence
end
+ def joined_label_ids(journal)
+ journal.label_journals.map(&:label_id).sort.join(",").presence
+ end
+
def participant_baseline_journal
journals = journable.journals.to_a
current_index = journals.index { |entry| entry.id == id }
diff --git a/spec/factories/journal/label_journal_factory.rb b/spec/factories/journal/label_journal_factory.rb
new file mode 100644
index 000000000000..207d4091c9eb
--- /dev/null
+++ b/spec/factories/journal/label_journal_factory.rb
@@ -0,0 +1,33 @@
+# 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.
+#++
+
+FactoryBot.define do
+ factory :journal_label_journal, class: "Journal::LabelJournal"
+end
diff --git a/spec/lib/open_project/journal_formatter/labels_spec.rb b/spec/lib/open_project/journal_formatter/labels_spec.rb
new file mode 100644
index 000000000000..b600aa5a19a3
--- /dev/null
+++ b/spec/lib/open_project/journal_formatter/labels_spec.rb
@@ -0,0 +1,100 @@
+# 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::Labels do
+ describe "#render" do
+ let(:label) { build_stubbed(:label, name: "A") }
+ let(:other_label) { build_stubbed(:label, name: "B") }
+ let(:work_package) { build_stubbed(:work_package) }
+ let(:journal) { build_stubbed(:work_package_journal, journable: work_package) }
+ let(:instance) { described_class.new(journal) }
+ let(:label_text) { "Labels" }
+
+ before do
+ allow(Label).to receive(:find_by).and_return(nil)
+
+ [label, other_label].each do |l|
+ allow(Label).to receive(:find_by).with(id: l.id).and_return(l)
+ end
+ end
+
+ context "when setting labels" do
+ it "renders the label names as the new value" do
+ expect(instance.render(:labels, [nil, "#{label.id},#{other_label.id}"]))
+ .to eq(I18n.t(:text_journal_set_to, label: label_text, value: "A, B"))
+ end
+ end
+
+ context "when changing labels" do
+ it "renders the old and new label names" do
+ expect(instance.render(:labels, [label.id.to_s, other_label.id.to_s]))
+ .to eq(I18n.t(:text_journal_changed_plain,
+ label: label_text,
+ linebreak: nil,
+ old: "A",
+ new: "B"))
+ end
+ end
+
+ context "when removing all labels" do
+ it "renders the old label names as deleted" do
+ expect(instance.render(:labels, ["#{label.id},#{other_label.id}", nil]))
+ .to eq(I18n.t(:text_journal_deleted, label: label_text, old: "A, B"))
+ end
+ end
+
+ context "with a label that no longer exists" do
+ it "renders only the existing label names" do
+ expect(instance.render(:labels, [nil, "#{label.id},99999"]))
+ .to eq(I18n.t(:text_journal_set_to, label: label_text, value: "A"))
+ end
+ end
+
+ context "when setting labels that no longer exist" do
+ it "renders no detail" do
+ expect(instance.render(:labels, [nil, "99999,99998"]))
+ .to eq(I18n.t(:text_journal_deleted_no_detail, label: label_text))
+ end
+ end
+
+ context "with html: false" do
+ it "renders plain text" do
+ expect(instance.render(:labels, [label.id.to_s, other_label.id.to_s], html: false))
+ .to eq(I18n.t(:text_journal_changed_plain,
+ label: "Labels",
+ linebreak: nil,
+ old: "A",
+ new: "B"))
+ end
+ end
+ end
+end
diff --git a/spec/models/journable/with_historic_attributes_spec.rb b/spec/models/journable/with_historic_attributes_spec.rb
index 0e79855b5c04..0e1fa113bdb5 100644
--- a/spec/models/journable/with_historic_attributes_spec.rb
+++ b/spec/models/journable/with_historic_attributes_spec.rb
@@ -821,4 +821,107 @@ def update_observed_in_versions(*versions)
end
end
end
+
+ describe "labels" do
+ shared_let(:label_a) { create(:label) }
+ shared_let(:label_b) { create(:label) }
+
+ let(:work_package) do
+ create(:work_package,
+ subject: "The labelled work package",
+ project:,
+ journals: {
+ created_at => {},
+ 1.day.ago => {}
+ })
+ end
+
+ subject { described_class.wrap(work_package, timestamps:) }
+
+ def update_labels(*labels)
+ work_package.labels = labels
+ work_package.save!
+ end
+
+ def seed_baseline_label_journal(label)
+ create(:journal_label_journal, journal: work_package.journals.first, label:)
+ end
+
+ describe "#at_timestamp" do
+ it "returns the labels from the journal snapshot, not the current ones" do
+ seed_baseline_label_journal(label_a)
+ update_labels(label_a, label_b)
+
+ expect(subject.at_timestamp(Timestamp.parse("2022-01-01T00:00:00Z")).labels)
+ .to contain_exactly(label_a)
+ expect(work_package.reload.labels)
+ .to contain_exactly(label_a, label_b)
+ end
+
+ it "omits snapshotted labels that were deleted since" do
+ update_labels(label_a, label_b)
+ label_b.destroy!
+
+ # 1 hour in the future so the journal written by the update above is
+ # unambiguously in the past, regardless of sub-second timing.
+ expect(subject.at_timestamp(Timestamp.parse(1.hour.from_now.iso8601)).labels)
+ .to contain_exactly(label_a)
+ end
+ end
+
+ describe "#changed_at_timestamp" do
+ context "when a label was added" do
+ it "marks the labels as changed" do
+ update_labels(label_a, label_b)
+
+ expect(subject.changed_at_timestamp(Timestamp.parse("2022-01-01T00:00:00Z")))
+ .to contain_exactly("labels")
+ end
+ end
+
+ context "when one of several labels was removed" do
+ it "marks only the labels as changed" do
+ # Journal rows are stamped by the database clock, so a past removal
+ # can't be journalled directly; seed the baseline snapshot instead.
+ seed_baseline_label_journal(label_a)
+ seed_baseline_label_journal(label_b)
+ update_labels(label_a)
+
+ expect(subject.changed_at_timestamp(Timestamp.parse("2022-01-01T00:00:00Z")))
+ .to contain_exactly("labels")
+ expect(subject.send(:changes_at_timestamp, Timestamp.parse("2022-01-01T00:00:00Z"))["labels"])
+ .to eq([[label_a.id, label_b.id].sort.join(","), label_a.id.to_s])
+ end
+ end
+
+ context "when all labels were removed" do
+ it "marks the labels as changed" do
+ seed_baseline_label_journal(label_a)
+ update_labels
+
+ expect(subject.changed_at_timestamp(Timestamp.parse("2022-01-01T00:00:00Z")))
+ .to contain_exactly("labels")
+ expect(subject.send(:changes_at_timestamp, Timestamp.parse("2022-01-01T00:00:00Z"))["labels"])
+ .to eq([label_a.id.to_s, nil])
+ end
+ end
+
+ context "when the label set is unchanged" do
+ it "reports no label change" do
+ expect(subject.changed_at_timestamp(Timestamp.parse("2022-01-01T00:00:00Z")))
+ .to be_empty
+ end
+ end
+ end
+
+ describe "#changes_at_timestamp" do
+ it "reports the joined old and new label ids" do
+ seed_baseline_label_journal(label_a)
+ update_labels(label_a, label_b)
+
+ expect(subject.send(:changes_at_timestamp, Timestamp.parse("2022-01-01T00:00:00Z"))["labels"])
+ .to eq([label_a.id.to_s, [label_a.id, label_b.id].sort.join(",")])
+ end
+ end
+ end
end
diff --git a/spec/models/journal/label_journal_spec.rb b/spec/models/journal/label_journal_spec.rb
new file mode 100644
index 000000000000..6d2ea252406f
--- /dev/null
+++ b/spec/models/journal/label_journal_spec.rb
@@ -0,0 +1,51 @@
+# 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 Journal::LabelJournal do
+ it "belongs to a label" do
+ expect(described_class.reflect_on_association(:label).macro).to eq(:belongs_to)
+ end
+
+ it "uses the label_journals table" do
+ expect(described_class.table_name).to eq("label_journals")
+ end
+
+ it "keeps its rows when the label is destroyed" do
+ journal = create(:work_package_journal)
+ label = create(:label)
+ label_journal = described_class.create!(journal:, label:)
+
+ label.destroy!
+
+ expect(label_journal.reload.label_id).to eq(label.id)
+ end
+end
diff --git a/spec/models/work_package/work_package_acts_as_journalized_spec.rb b/spec/models/work_package/work_package_acts_as_journalized_spec.rb
index c417f6eddb23..73f7b78e45c1 100644
--- a/spec/models/work_package/work_package_acts_as_journalized_spec.rb
+++ b/spec/models/work_package/work_package_acts_as_journalized_spec.rb
@@ -576,6 +576,142 @@ def set_target_versions(versions)
end
end
+ context "on label changes", with_settings: { journal_aggregation_time_minutes: 0 } do
+ shared_let(:label) { create(:label) }
+ shared_let(:other_label) { create(:label) }
+
+ shared_let(:journable) do
+ create(:work_package)
+ end
+
+ def set_labels(labels)
+ journable.labelings.destroy_all
+ labels.each { journable.labelings.create!(label: it) }
+ journable.save!
+ end
+
+ context "when setting labels" do
+ it "creates a new journal listing the labels in the details" do
+ expect { set_labels([label, other_label]) }
+ .to change { journable.journals.count }.by(1)
+
+ expect(journable.last_journal.details["labels"])
+ .to eq([nil, [label.id, other_label.id].sort.join(",")])
+ end
+
+ it "touches the journable to match the journal's timestamp" do
+ expect { set_labels([label]) }
+ .to change { journable.reload.updated_at }
+
+ expect(journable.updated_at).to eq(journable.last_journal.updated_at)
+ end
+ end
+
+ context "on work package creation" do
+ it "creates an initial journal listing the labels in the details" do
+ work_package = create(:work_package, labels: [label])
+
+ expect(work_package.journals.count).to eq(1)
+ expect(work_package.last_journal.details["labels"])
+ .to eq([nil, label.id.to_s])
+ expect(Journal::LabelJournal.where(journal_id: work_package.last_journal.id).count)
+ .to eq(1)
+ end
+ end
+
+ context "when replacing a label" do
+ before do
+ set_labels([label])
+ end
+
+ it "creates a new journal with the old and new labels in the details" do
+ expect { set_labels([other_label]) }
+ .to change { journable.journals.count }.by(1)
+
+ expect(journable.last_journal.details["labels"])
+ .to eq([label.id.to_s, other_label.id.to_s])
+ end
+ end
+
+ context "when removing all labels" do
+ before do
+ set_labels([label])
+ end
+
+ it "creates a new journal with an empty new value in the details" do
+ expect { set_labels([]) }
+ .to change { journable.journals.count }.by(1)
+
+ expect(journable.last_journal.details["labels"])
+ .to eq([label.id.to_s, nil])
+ end
+ end
+
+ context "when saving with unchanged labels" do
+ before do
+ set_labels([label])
+ end
+
+ it "creates no journal and does not touch the journable" do
+ expect { set_labels([label]) }
+ .to not_change { journable.journals.count }
+ .and(not_change { journable.reload.updated_at })
+ end
+ end
+
+ context "when another labelable shares the work package's id" do
+ it "journals only the work package's own labels" do
+ Labeling.create!(label:, labelable_type: "Project", labelable_id: journable.id)
+
+ expect { set_labels([other_label]) }
+ .to change { journable.journals.count }.by(1)
+
+ expect(journable.last_journal.details["labels"])
+ .to eq([nil, other_label.id.to_s])
+ end
+ end
+
+ context "within aggregation time", with_settings: { journal_aggregation_time_minutes: 5 } do
+ # The initial journal lies outside the aggregation window so that only
+ # the journals created by the examples can aggregate with each other.
+ shared_let(:journable) do
+ create(:work_package,
+ subject: "Initial subject",
+ journals: { 10.minutes.ago => { user: } })
+ end
+
+ context "when changing labels again" do
+ before do
+ set_labels([label])
+ end
+
+ it "aggregates into the previous journal with cumulative details" do
+ expect { set_labels([label, other_label]) }
+ .not_to change { journable.journals.count }
+
+ expect(journable.last_journal.details["labels"])
+ .to eq([nil, [label.id, other_label.id].sort.join(",")])
+ end
+ end
+
+ context "when changing labels shortly after another change" do
+ before do
+ journable.update!(subject: "Changed subject")
+ end
+
+ it "aggregates into one journal capturing both changes" do
+ expect { set_labels([label]) }
+ .not_to change { journable.journals.count }
+
+ expect(journable.last_journal.details["subject"])
+ .to eq(["Initial subject", "Changed subject"])
+ expect(journable.last_journal.details["labels"])
+ .to eq([nil, label.id.to_s])
+ end
+ end
+ end
+ end
+
context "on observed in version changes", with_settings: { journal_aggregation_time_minutes: 0 } do
shared_let(:journable) do
create(:work_package)
diff --git a/spec/services/journals/create_service/association_spec.rb b/spec/services/journals/create_service/association_spec.rb
index a6ccd0e9ef12..c5babbf1ce4b 100644
--- a/spec/services/journals/create_service/association_spec.rb
+++ b/spec/services/journals/create_service/association_spec.rb
@@ -65,11 +65,20 @@
allow(journable).to receive(:respond_to?).with(:file_links).and_return(false)
allow(journable).to receive(:respond_to?).with(:agenda_items).and_return(false)
allow(journable).to receive(:respond_to?).with(:phases).and_return(false)
+ allow(journable).to receive(:respond_to?).with(:labels).and_return(false)
associations = described_class.for(journable)
expect(associations.map(&:class)).to include(Journals::CreateService::Attachable)
end
+ it "includes Labelable for a journable that responds to labels" do
+ journable = instance_double(WorkPackage, customizable?: false, respond_to?: false)
+ allow(journable).to receive(:respond_to?).with(:labels).and_return(true)
+
+ associations = described_class.for(journable)
+ expect(associations.map(&:class)).to include(Journals::CreateService::Labelable)
+ end
+
it "excludes associations whose #associated? returns false" do
journable = instance_double(WorkPackage, customizable?: false, respond_to?: false)
allow(journable).to receive(:respond_to?).and_return(false)