From d2db840950b208c700ddcc4edcc4deae9cdf3aed Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Mon, 14 Sep 2026 19:00:03 +0300 Subject: [PATCH] Journal work package labels Snapshots the label set per journal so the activity tab renders label changes and baseline comparison loads historic labels. Snapshot rows keep no foreign key to labels so history survives a hard delete. https://community.openproject.org/wp/79963 --- .../journable/with_historic_attributes.rb | 32 +++-- .../with_historic_attributes/loader.rb | 29 +++- app/models/journal.rb | 2 + app/models/journal/label_journal.rb | 35 +++++ app/models/work_package/journalized.rb | 1 + .../journals/create_service/association.rb | 2 +- .../journals/create_service/labelable.rb | 86 +++++++++++ config/locales/en.yml | 4 + .../20260914130000_create_label_journals.rb | 39 +++++ ...ined_versions.rb => joined_association.rb} | 9 +- lib/open_project/journal_formatter/labels.rb | 34 +++++ .../journal_formatter/observed_in_versions.rb | 2 +- .../journal_formatter/target_versions.rb | 2 +- .../lib/journal_changes.rb | 15 ++ .../journal/label_journal_factory.rb | 33 +++++ .../journal_formatter/labels_spec.rb | 100 +++++++++++++ .../with_historic_attributes_spec.rb | 103 +++++++++++++ spec/models/journal/label_journal_spec.rb | 51 +++++++ .../work_package_acts_as_journalized_spec.rb | 136 ++++++++++++++++++ .../create_service/association_spec.rb | 9 ++ 20 files changed, 703 insertions(+), 21 deletions(-) create mode 100644 app/models/journal/label_journal.rb create mode 100644 app/services/journals/create_service/labelable.rb create mode 100644 db/migrate/20260914130000_create_label_journals.rb rename lib/open_project/journal_formatter/{joined_versions.rb => joined_association.rb} (80%) create mode 100644 lib/open_project/journal_formatter/labels.rb create mode 100644 spec/factories/journal/label_journal_factory.rb create mode 100644 spec/lib/open_project/journal_formatter/labels_spec.rb create mode 100644 spec/models/journal/label_journal_spec.rb 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)