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)