diff --git a/app/models/work_package/journalized.rb b/app/models/work_package/journalized.rb index 01347465f5ec..90be69c56289 100644 --- a/app/models/work_package/journalized.rb +++ b/app/models/work_package/journalized.rb @@ -79,7 +79,7 @@ def self.event_type def self.event_url Proc.new do |o| - { controller: :work_packages, action: :show, id: o.id } + { controller: :work_packages, action: :show, id: o.display_id } end end end diff --git a/frontend/src/app/core/global_search/input/global-search-input.component.html b/frontend/src/app/core/global_search/input/global-search-input.component.html index 0a5d28b4fc42..a1568f29b670 100644 --- a/frontend/src/app/core/global_search/input/global-search-input.component.html +++ b/frontend/src/app/core/global_search/input/global-search-input.component.html @@ -79,8 +79,8 @@ } @else {
diff --git a/frontend/src/app/core/global_search/input/global-search-input.component.spec.ts b/frontend/src/app/core/global_search/input/global-search-input.component.spec.ts new file mode 100644 index 000000000000..0549b10ecf46 --- /dev/null +++ b/frontend/src/app/core/global_search/input/global-search-input.component.spec.ts @@ -0,0 +1,115 @@ +//-- 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. +//++ + +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; +import { GlobalSearchInputComponent } from './global-search-input.component'; + +// followItem is verified through the prototype against a stand-in context, avoiding +// a real component instance whose many injected dependencies this branch never uses. +describe('GlobalSearchInputComponent#followItem', () => { + let wpPathArgs:string[]; + let searchInScopeArgs:string[]; + let context:Pick & { searchInScope:(scope:string) => void }; + + function callFollowItem(item:Parameters[0]):void { + GlobalSearchInputComponent.prototype.followItem.call(context, item); + } + + beforeEach(() => { + wpPathArgs = []; + searchInScopeArgs = []; + context = { + wpPath: (id:string):string => { + wpPathArgs.push(id); + // A fragment keeps followItem's window.location assignment from navigating the runner. + return '#stub'; + }, + selectedItem: undefined, + searchInScope: (scope:string):void => { + searchInScopeArgs.push(scope); + }, + }; + }); + + describe('when item is a work package resource', () => { + // Build a real WorkPackageResource off its prototype and feed it a HAL $source, + // so followItem exercises the production displayId getter rather than a stub. + function buildWorkPackage(source:{ id:number, displayId?:string }):WorkPackageResource { + const item = Object.create(WorkPackageResource.prototype) as WorkPackageResource; + item.$source = source; + return item; + } + + it('is recognised as a HalResource', () => { + expect(buildWorkPackage({ id: 42 }) instanceof HalResource).toBe(true); + }); + + describe('in semantic mode (source carries a semantic displayId)', () => { + let item:WorkPackageResource; + + beforeEach(() => { + item = buildWorkPackage({ id: 42, displayId: 'PROJ-42' }); + }); + + it('navigates via the semantic displayId, not the numeric id', () => { + callFollowItem(item); + expect(wpPathArgs).toEqual(['PROJ-42']); + expect(wpPathArgs).not.toContain('42'); + }); + + it('sets selectedItem to the item', () => { + callFollowItem(item); + expect(context.selectedItem).toBe(item); + }); + }); + + describe('in classic mode (source has only the numeric id)', () => { + it('falls back to the numeric id through displayId', () => { + callFollowItem(buildWorkPackage({ id: 42 })); + expect(wpPathArgs).toEqual(['42']); + }); + }); + }); + + describe('when item is a scope option (not a HalResource)', () => { + it('delegates to searchInScope and does not call wpPath', () => { + callFollowItem({ projectScope: 'current_project', text: 'In this project ↵' }); + expect(searchInScopeArgs).toEqual(['current_project']); + expect(wpPathArgs).toEqual([]); + }); + }); + + describe('when item is undefined', () => { + it('does nothing', () => { + callFollowItem(undefined); + expect(wpPathArgs).toEqual([]); + expect(searchInScopeArgs).toEqual([]); + }); + }); +}); diff --git a/frontend/src/app/core/global_search/input/global-search-input.component.ts b/frontend/src/app/core/global_search/input/global-search-input.component.ts index 5f693bd17280..a6af6a09f856 100644 --- a/frontend/src/app/core/global_search/input/global-search-input.component.ts +++ b/frontend/src/app/core/global_search/input/global-search-input.component.ts @@ -262,8 +262,8 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { public followItem(item:WorkPackageResource|SearchOptionItem|undefined):void { this.selectedItem = item; - if (item instanceof HalResource) { - window.location.href = this.wpPath(item.id!); + if (item instanceof WorkPackageResource) { + window.location.href = this.wpPath(item.displayId); } else if (item) { this.searchInScope(item.projectScope); } diff --git a/lib_static/plugins/acts_as_event/lib/acts_as_event.rb b/lib_static/plugins/acts_as_event/lib/acts_as_event.rb index 1d3e6ea90404..c8199c1d54a9 100644 --- a/lib_static/plugins/acts_as_event/lib/acts_as_event.rb +++ b/lib_static/plugins/acts_as_event/lib/acts_as_event.rb @@ -28,9 +28,9 @@ # See COPYRIGHT and LICENSE files for more details. #++ -# This now only seems to be used when rendering atom responses. -# Search as well as activities do not rely on it. -# Thus, whenever an atom link is removed for a resource, acts_as_event within that model can also be removed. +# The event_* methods defined here power the server-rendered search results page +# and atom feeds. The Activities subsystem renders through its own providers, so +# a model can drop acts_as_event only once neither search nor atom references it. module Redmine module Acts diff --git a/spec/features/search/search_spec.rb b/spec/features/search/search_spec.rb index 453566c40645..b7a8227e8d1c 100644 --- a/spec/features/search/search_spec.rb +++ b/spec/features/search/search_spec.rb @@ -461,6 +461,29 @@ def expect_range(param_a, param_b) end end + describe "when semantic work package IDs are active", + with_settings: { work_packages_identifier: "semantic" } do + let(:run_visit) { false } + let(:semantic_project) { create(:project, :semantic) } + let(:semantic_wp) do + create(:work_package, subject: "SemanticIdentifierTest WP", project: semantic_project) + end + + before do + semantic_wp + visit search_path(scope: "all", q: "SemanticIdentifierTest") + end + + it "links results to the semantic identifier URL, not the numeric ID" do + identifier = semantic_wp.reload.identifier + + within("dt.work_package-edit") do + expect(page).to have_link(href: %r{/work_packages/#{Regexp.escape(identifier)}(?:$|[#?])}) + expect(page).to have_no_link(href: %r{/work_packages/#{semantic_wp.id}(?:$|[#?])}) + end + end + end + describe "search for notes" do let(:work_package) { work_packages[0] } let!(:note_one) do diff --git a/spec/models/work_package/work_package_acts_as_event_spec.rb b/spec/models/work_package/work_package_acts_as_event_spec.rb index 21a23ba4ffef..ccd38692587a 100644 --- a/spec/models/work_package/work_package_acts_as_event_spec.rb +++ b/spec/models/work_package/work_package_acts_as_event_spec.rb @@ -35,9 +35,21 @@ let(:stub_work_package) { build_stubbed(:work_package) } describe "#event_url" do - let(:expected_url) { { controller: :work_packages, action: :show, id: stub_work_package.id } } + context "in classic mode" do + let(:expected_url) { { controller: :work_packages, action: :show, id: stub_work_package.id } } - it { expect(stub_work_package.event_url).to eq(expected_url) } + it { expect(stub_work_package.event_url).to eq(expected_url) } + end + + context "in semantic mode", with_settings: { work_packages_identifier: "semantic" } do + let(:project) { create(:project, :semantic) } + let(:work_package) { create(:work_package, project:) } + + it "links to the semantic identifier rather than the numeric id" do + expect(work_package.event_url) + .to eq(controller: :work_packages, action: :show, id: work_package.reload.identifier) + end + end end end end