diff --git a/app/models/activities/work_package_activity_provider.rb b/app/models/activities/work_package_activity_provider.rb index 7817fa4e505..0210fb5b294 100644 --- a/app/models/activities/work_package_activity_provider.rb +++ b/app/models/activities/work_package_activity_provider.rb @@ -33,8 +33,9 @@ class Activities::WorkPackageActivityProvider < Activities::BaseActivityProvider permission: :view_work_packages def extend_event_query(query) - query.join(types_table).on(activity_journals_table[:type_id].eq(types_table[:id])) - query.join(statuses_table).on(activity_journals_table[:status_id].eq(statuses_table[:id])) + join_types_table(query) + join_statuses_table(query) + join_activitied_table(query) end def event_query_projection @@ -42,12 +43,13 @@ class Activities::WorkPackageActivityProvider < Activities::BaseActivityProvider activity_journal_projection_statement(:subject, "subject"), activity_journal_projection_statement(:project_id, "project_id"), projection_statement(statuses_table, :is_closed, "status_closed"), - projection_statement(types_table, :name, "type_name") + projection_statement(types_table, :name, "type_name"), + projection_statement(activitied_table, :identifier, "identifier") ] end - def self.work_package_title(id, subject, type_name) - "#{type_name} ##{id}: #{subject}" + def self.work_package_title(id, subject, type_name, identifier = nil) + "#{type_name} #{WorkPackage::SemanticIdentifier.formatted_id_for(id, identifier)}: #{subject}" end protected @@ -55,7 +57,8 @@ class Activities::WorkPackageActivityProvider < Activities::BaseActivityProvider def event_title(event) self.class.work_package_title(event["journable_id"], event["subject"], - event["type_name"]) + event["type_name"], + event["identifier"]) end def event_type(event) @@ -63,11 +66,11 @@ class Activities::WorkPackageActivityProvider < Activities::BaseActivityProvider end def event_path(event) - url_helpers.work_package_path(event["journable_id"]) + url_helpers.work_package_path(WorkPackage::SemanticIdentifier.display_id_for(event["journable_id"], event["identifier"])) end def event_url(event) - url_helpers.work_package_url(event["journable_id"], + url_helpers.work_package_url(WorkPackage::SemanticIdentifier.display_id_for(event["journable_id"], event["identifier"]), anchor: notes_anchor(event)) end @@ -79,6 +82,18 @@ class Activities::WorkPackageActivityProvider < Activities::BaseActivityProvider version > 1 ? "note-#{version - 1}" : "" end + def join_types_table(query) + query.join(types_table).on(activity_journals_table[:type_id].eq(types_table[:id])) + end + + def join_statuses_table(query) + query.join(statuses_table).on(activity_journals_table[:status_id].eq(statuses_table[:id])) + end + + def join_activitied_table(query) + query.join(activitied_table).on(journals_table[:journable_id].eq(activitied_table[:id])) + end + def types_table @types_table = Type.arel_table end diff --git a/app/models/work_package/semantic_identifier.rb b/app/models/work_package/semantic_identifier.rb index 43c9769d59e..cadd43670b4 100644 --- a/app/models/work_package/semantic_identifier.rb +++ b/app/models/work_package/semantic_identifier.rb @@ -121,25 +121,34 @@ module WorkPackage::SemanticIdentifier end end - # Returns formatted value for inline UI display. - # * Semantic mode: "PROJ-42" (no prefix — self-describing) - # * Classic mode: "#42" (hash-prefixed) + # Returns the user-facing identifier for a work package given its id and identifier. + # In semantic mode: the project-based identifier (e.g. "PROJ-42") + # In classic mode: the numeric database ID (even if identifier is set in the DB) + def self.display_id_for(id, identifier) + return id unless Setting::WorkPackageIdentifier.semantic? + + identifier.presence || id + end + + # Formats a resolved display id for inline UI display. + # Semantic mode: "PROJ-42" (no prefix — self-describing) + # Classic mode: "#42" (hash-prefixed) def self.format_display_id(display_id) display_id.is_a?(String) && display_id.match?(/[A-Za-z]/) ? display_id : "##{display_id}" end + # Returns the inline-formatted identifier for a work package given its id and identifier. + def self.formatted_id_for(id, identifier) + format_display_id(display_id_for(id, identifier)) + end + # Returns the user-facing identifier for this work package. # In semantic mode: the project-based identifier (e.g. "PROJ-42") # In classic mode: the numeric database ID def display_id - return id unless Setting::WorkPackageIdentifier.semantic? - - identifier.presence || id + WorkPackage::SemanticIdentifier.display_id_for(id, identifier) end - # Returns the identifier formatted for inline UI display. - # Semantic mode: "PROJ-42" (no prefix — self-describing) - # Classic mode: "#42" (hash-prefixed) def formatted_id WorkPackage::SemanticIdentifier.format_display_id(display_id) end diff --git a/modules/costs/app/models/activities/time_entry_activity_provider.rb b/modules/costs/app/models/activities/time_entry_activity_provider.rb index 177294cfa79..31e1756e2e3 100644 --- a/modules/costs/app/models/activities/time_entry_activity_provider.rb +++ b/modules/costs/app/models/activities/time_entry_activity_provider.rb @@ -45,6 +45,7 @@ class Activities::TimeEntryActivityProvider < Activities::BaseActivityProvider activity_journal_projection_statement(:entity_id, "entity_id"), projection_statement(projects_table, :name, "project_name"), projection_statement(work_packages_table, :subject, "work_package_subject"), + projection_statement(work_packages_table, :identifier, "work_package_identifier"), projection_statement(meetings_table, :title, "meeting_title"), projection_statement(types_table, :name, "type_name") ] @@ -76,7 +77,8 @@ class Activities::TimeEntryActivityProvider < Activities::BaseActivityProvider if event["entity_type"] == "WorkPackage" Activities::WorkPackageActivityProvider.work_package_title(event["entity_id"], event["work_package_subject"], - event["type_name"]) + event["type_name"], + event["work_package_identifier"]) elsif event["entity_type"] == "Meeting" event["meeting_title"] end diff --git a/modules/costs/spec/models/activities/time_entry_activity_provider_spec.rb b/modules/costs/spec/models/activities/time_entry_activity_provider_spec.rb new file mode 100644 index 00000000000..be8915117e6 --- /dev/null +++ b/modules/costs/spec/models/activities/time_entry_activity_provider_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, 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 Activities::TimeEntryActivityProvider do + let(:event_scope) { "time_entries" } + let(:user) { create(:admin) } + let(:work_package) do + User.execute_as(user) do + create(:work_package, project:) + end + end + + let(:events) do + described_class + .find_events(event_scope, user, Time.zone.yesterday.to_datetime, Time.zone.tomorrow.to_datetime, {}) + end + + before do + User.execute_as(user) do + create(:time_entry, entity: work_package, project:, user:) + end + end + + describe ".find_events" do + context "when classic IDs are enabled", with_settings: { work_packages_identifier: "classic" } do + let(:project) { create(:project) } + + it "uses the numeric identifier in the event title" do + expect(events[0].event_title).to include("##{work_package.id}") + end + end + + context "when semantic IDs are enabled", with_settings: { work_packages_identifier: "semantic" } do + let(:project) { create(:project, :semantic) } + + it "uses the semantic identifier in the event title" do + semantic_id = work_package.reload.identifier + + expect(events[0].event_title).to include(semantic_id) + expect(events[0].event_title).not_to include("##{work_package.id}") + end + end + end +end diff --git a/spec/models/activities/work_package_activity_provider_spec.rb b/spec/models/activities/work_package_activity_provider_spec.rb index 56763f11495..d1a8a2b3aa2 100644 --- a/spec/models/activities/work_package_activity_provider_spec.rb +++ b/spec/models/activities/work_package_activity_provider_spec.rb @@ -47,8 +47,8 @@ RSpec.describe Activities::WorkPackageActivityProvider do describe ".find_events" do context "when a work package has been created" do - let(:subject) do - Activities::WorkPackageActivityProvider + subject do + described_class .find_events(event_scope, user, Time.zone.yesterday.to_datetime, Time.zone.tomorrow.to_datetime, {}) end @@ -63,11 +63,11 @@ RSpec.describe Activities::WorkPackageActivityProvider do end end - context "should be selected and ordered correctly" do + context "when selecting and ordering events" do let!(:work_packages) { (1..5).map { create(:work_package, author: user).id.to_s } } - let(:subject) do - Activities::WorkPackageActivityProvider + subject do + described_class .find_events(event_scope, user, Time.zone.yesterday.to_datetime, Time.zone.tomorrow.to_datetime, limit: 3) .map { |a| a.journable_id.to_s } end @@ -76,8 +76,8 @@ RSpec.describe Activities::WorkPackageActivityProvider do end context "when a work package has been created and then closed" do - let(:subject) do - Activities::WorkPackageActivityProvider + subject do + described_class .find_events(event_scope, user, Time.zone.yesterday.to_datetime, Time.zone.tomorrow.to_datetime, limit: 10) end @@ -99,6 +99,39 @@ RSpec.describe Activities::WorkPackageActivityProvider do end end + context "when semantic IDs are enabled", with_settings: { work_packages_identifier: "semantic" } do + # Override outer let!(:work_packages) so it doesn't force WP creation before the stub is active + let!(:work_packages) { [] } + + let(:project) { create(:project, :semantic) } + let(:work_package) do + User.execute_as(user) do + create(:work_package, project:) + end + end + + let(:events) do + described_class + .find_events(event_scope, user, Time.zone.yesterday.to_datetime, Time.zone.tomorrow.to_datetime, {}) + end + + before do + work_package + end + + it "uses the semantic identifier in the event title" do + semantic_id = work_package.reload.identifier + expect(events[0].event_title).to include(semantic_id) + expect(events[0].event_title).not_to include("##{work_package.id}") + end + + it "uses the semantic identifier in the event path" do + semantic_id = work_package.reload.identifier + expect(events[0].event_path).to eq("/work_packages/#{semantic_id}") + expect(events[0].event_path).not_to eq("/work_packages/#{work_package.id}") + end + end + context "for a non admin user" do let(:project) { create(:project) } let(:child_project1) { create(:project, parent: project) } @@ -135,11 +168,11 @@ RSpec.describe Activities::WorkPackageActivityProvider do end end - let(:subject) do + subject do # lft and rgt need to be updated project.reload - Activities::WorkPackageActivityProvider + described_class .find_events( event_scope, user,