diff --git a/app/models/work_package/semantic_identifier.rb b/app/models/work_package/semantic_identifier.rb index 989adbaf249a..329e87fc1e50 100644 --- a/app/models/work_package/semantic_identifier.rb +++ b/app/models/work_package/semantic_identifier.rb @@ -60,7 +60,9 @@ def relation # In semantic mode: the project-based identifier (e.g. "PROJ-42") # In classic mode: the numeric database ID def display_id - Setting::WorkPackageIdentifier.semantic_mode_active? ? identifier : id + return id unless Setting::WorkPackageIdentifier.semantic_mode_active? + + identifier.presence || id end # Allocates the next semantic identifier in the current project and assigns it to the WP. diff --git a/frontend/src/app/features/bim/ifc_models/openproject-ifc-models.routes.ts b/frontend/src/app/features/bim/ifc_models/openproject-ifc-models.routes.ts index b6f8a45e4029..1cbcdf7a52b4 100644 --- a/frontend/src/app/features/bim/ifc_models/openproject-ifc-models.routes.ts +++ b/frontend/src/app/features/bim/ifc_models/openproject-ifc-models.routes.ts @@ -34,6 +34,7 @@ import { WorkPackageNewFullViewComponent } from 'core-app/features/work-packages import { WorkPackagesBaseComponent } from 'core-app/features/work-packages/routing/wp-base/wp--base.component'; import { BcfSplitLeftComponent } from 'core-app/features/bim/ifc_models/bcf/split/left/bcf-split-left.component'; import { BcfSplitRightComponent } from 'core-app/features/bim/ifc_models/bcf/split/right/bcf-split-right.component'; +import { WP_ID_URL_PATTERN } from 'core-app/shared/helpers/work-package-id-pattern'; export const sidemenuId = 'bim_sidemenu'; @@ -99,7 +100,7 @@ export const IFC_ROUTES:Ng2StateDeclaration[] = [ }, { name: 'bim.partitioned.show', - url: '/show/{workPackageId:[0-9]+}', + url: `/show/{workPackageId:${WP_ID_URL_PATTERN}}`, data: { baseRoute: 'bim.partitioned.list', partition: '-left-only', diff --git a/frontend/src/app/features/hal/resources/work-package-resource.spec.ts b/frontend/src/app/features/hal/resources/work-package-resource.spec.ts index 6f755c367650..aa47c69715e9 100644 --- a/frontend/src/app/features/hal/resources/work-package-resource.spec.ts +++ b/frontend/src/app/features/hal/resources/work-package-resource.spec.ts @@ -110,6 +110,87 @@ describe('WorkPackage', () => { }); }); + describe('displayId', () => { + afterEach(() => { + source = undefined; + }); + + describe('when displayId is present (semantic mode)', () => { + beforeEach(() => { + source = { id: 42, displayId: 'PROJ-7' }; + createWorkPackage(); + }); + + it('should return the semantic identifier', () => { + expect(workPackage.displayId).toEqual('PROJ-7'); + }); + + it('should not override the numeric id', () => { + expect(workPackage.id).toEqual('42'); + }); + }); + + describe('when displayId is present (classic mode)', () => { + beforeEach(() => { + source = { id: 42, displayId: '42' }; + createWorkPackage(); + }); + + it('should return the numeric displayId as string', () => { + expect(workPackage.displayId).toEqual('42'); + }); + }); + +}); + + describe('formattedId', () => { + afterEach(() => { + source = undefined; + }); + + it('should return semantic identifier without hash prefix', () => { + source = { id: 42, displayId: 'PROJ-7' }; + createWorkPackage(); + + expect(workPackage.formattedId).toEqual('PROJ-7'); + }); + + it('should prefix numeric id with # in classic mode', () => { + source = { id: 42, displayId: '42' }; + createWorkPackage(); + + expect(workPackage.formattedId).toEqual('#42'); + }); + +}); + + describe('subjectWithId', () => { + afterEach(() => { + source = undefined; + }); + + it('should include semantic displayId without hash in parentheses', () => { + source = { id: 42, displayId: 'PROJ-7', subject: 'Fix the bug' }; + createWorkPackage(); + + expect(workPackage.subjectWithId()).toEqual('Fix the bug (PROJ-7)'); + }); + + it('should include hash-prefixed numeric id in classic mode', () => { + source = { id: 42, displayId: '42', subject: 'Fix the bug' }; + createWorkPackage(); + + expect(workPackage.subjectWithId()).toEqual('Fix the bug (#42)'); + }); + + it('should omit id suffix for new resources', () => { + source = { subject: 'New task' }; + createWorkPackage(); + + expect(workPackage.subjectWithId()).toEqual('New task'); + }); + }); + describe('when retrieving `canAddAttachment`', () => { beforeEach(createWorkPackage); diff --git a/frontend/src/app/features/hal/resources/work-package-resource.ts b/frontend/src/app/features/hal/resources/work-package-resource.ts index 090a94e5cd0f..1210ce87e1b2 100644 --- a/frontend/src/app/features/hal/resources/work-package-resource.ts +++ b/frontend/src/app/features/hal/resources/work-package-resource.ts @@ -125,6 +125,24 @@ export class WorkPackageBaseResource extends HalResource { public subject:string; + /** + * Returns the user-facing work package identifier. + * "PROJ-42" in semantic mode, "42" in classic mode. + */ + public get displayId():string { + return this.$source.displayId?.toString() ?? this.id?.toString() ?? ''; + } + + /** + * Returns the work package identifier formatted for inline UI display. + * Classic mode: `#42` (hash-prefixed numeric ID) + * Semantic mode: `PROJ-42` (no prefix — the identifier is self-describing) + */ + public get formattedId():string { + const wpId = this.displayId; + return /[A-Za-z]/.test(wpId) ? wpId : `#${wpId}`; + } + public updatedAt:Date; public lockVersion:number; @@ -170,7 +188,7 @@ export class WorkPackageBaseResource extends HalResource { } /** - * Return ": (#)" if type and id are known. + * Return ": ()" if type and id are known. */ public subjectWithType(truncateSubject = 40):string { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access @@ -178,10 +196,10 @@ export class WorkPackageBaseResource extends HalResource { } /** - * Return " (#)" if the id is known. + * Return " ()" if the id is known. */ public subjectWithId(truncateSubject = 40):string { - const id = isNewResource(this) ? '' : ` (#${this.id || ''})`; + const id = isNewResource(this) ? '' : ` (${this.formattedId})`; return `${this.truncatedSubject(truncateSubject)}${id}`; } diff --git a/frontend/src/app/features/work-packages/components/wp-single-view/wp-single-view.component.ts b/frontend/src/app/features/work-packages/components/wp-single-view/wp-single-view.component.ts index 4388ed66d312..2dd276ba8130 100644 --- a/frontend/src/app/features/work-packages/components/wp-single-view/wp-single-view.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-single-view/wp-single-view.component.ts @@ -294,7 +294,7 @@ export class WorkPackageSingleViewComponent extends UntilDestroyedMixin implemen * Returns the work package label */ public get idLabel():string { - return `#${this.workPackage.id || ''}`; + return this.workPackage.formattedId; } public showSwitchToProjectBanner():boolean { diff --git a/frontend/src/app/features/work-packages/routing/split-view-routes.template.ts b/frontend/src/app/features/work-packages/routing/split-view-routes.template.ts index 8b18299467ff..8b81d3dbff65 100644 --- a/frontend/src/app/features/work-packages/routing/split-view-routes.template.ts +++ b/frontend/src/app/features/work-packages/routing/split-view-routes.template.ts @@ -30,6 +30,7 @@ import { WorkPackageNewSplitViewComponent } from 'core-app/features/work-package import { Ng2StateDeclaration } from '@uirouter/angular'; import { ComponentType } from '@angular/cdk/overlay'; import { WpTabWrapperComponent } from 'core-app/features/work-packages/components/wp-tabs/components/wp-tab-wrapper/wp-tab-wrapper.component'; +import { WP_ID_URL_PATTERN } from 'core-app/shared/helpers/work-package-id-pattern'; /** * Return a set of routes for a split view mounted under the given base route, @@ -66,7 +67,7 @@ export function makeSplitViewRoutes(baseRoute:string, return [ { name: `${routeName}.details`, - url: '/details/{workPackageId:[0-9]+}', + url: `/details/{workPackageId:${WP_ID_URL_PATTERN}}`, redirectTo: (trans) => { const params = trans.params('to'); return { diff --git a/frontend/src/app/shared/helpers/work-package-id-pattern.ts b/frontend/src/app/shared/helpers/work-package-id-pattern.ts new file mode 100644 index 000000000000..b189e59120ad --- /dev/null +++ b/frontend/src/app/shared/helpers/work-package-id-pattern.ts @@ -0,0 +1,8 @@ +/** + * URL-safe pattern that matches work package identifiers: + * numeric IDs ("123") and semantic identifiers ("PROJ-42"). + * + * Used in UI Router route definitions so that both forms are accepted in URLs. + * The backend equivalent lives in WorkPackage::SemanticIdentifier::ID_ROUTE_CONSTRAINT. + */ +export const WP_ID_URL_PATTERN = '\\d+|[A-Za-z][A-Za-z0-9_]*-\\d+'; diff --git a/lib/api/v3/work_packages/work_package_sql_representer.rb b/lib/api/v3/work_packages/work_package_sql_representer.rb index 84a7b89ec9ac..8c2f52e76569 100644 --- a/lib/api/v3/work_packages/work_package_sql_representer.rb +++ b/lib/api/v3/work_packages/work_package_sql_representer.rb @@ -78,7 +78,7 @@ class WorkPackageSqlRepresenter property :displayId, representation: ->(*) { - Setting::WorkPackageIdentifier.semantic_mode_active? ? "identifier" : "id::text" + Setting::WorkPackageIdentifier.semantic_mode_active? ? "COALESCE(identifier, id::text)" : "id::text" } property :subject diff --git a/spec/features/work_packages/table/context_menu/context_menu_shared_examples.rb b/spec/features/work_packages/table/context_menu/context_menu_shared_examples.rb index 5dcbc4d33fc1..730cd84b9a14 100644 --- a/spec/features/work_packages/table/context_menu/context_menu_shared_examples.rb +++ b/spec/features/work_packages/table/context_menu/context_menu_shared_examples.rb @@ -96,5 +96,33 @@ wp = WorkPackage.last expect(wp.parent).to eq work_package end + + context "with semantic identifiers enabled", + with_flag: { semantic_work_package_ids: true }, + with_settings: { work_packages_identifier: "semantic" } do + it "uses numeric parent_id in the URL and sets the parent correctly" do + # Ensure the WP has a semantic identifier so we can verify the URL uses numeric PK + work_package.allocate_and_register_semantic_id if work_package.identifier.blank? + + open_context_menu.call + menu.choose("Create new child") + expect(page).to have_css(".inline-edit--container.subject input") + + expect(current_url).to match(/parent_id=#{work_package.id}/) + expect(current_url).not_to match(/parent_id=#{Regexp.escape(work_package.identifier)}/) + + split_view = Pages::SplitWorkPackageCreate.new project: work_package.project + subject = split_view.edit_field(:subject) + subject.set_value "Semantic child" + expect(page).to have_field("wp-new-inline-edit--field-subject", with: "Semantic child", wait: 10) + subject.submit_by_enter + + split_view.expect_and_dismiss_toaster message: "Successful creation." + expect(page).to have_test_selector("op-wp-breadcrumb", text: "Parent:\n#{work_package.subject}") + + child = WorkPackage.last + expect(child.parent).to eq work_package + end + end end end diff --git a/spec/lib/api/v3/work_packages/work_package_representer_spec.rb b/spec/lib/api/v3/work_packages/work_package_representer_spec.rb index 459ce56e728d..3cf332b8a4b5 100644 --- a/spec/lib/api/v3/work_packages/work_package_representer_spec.rb +++ b/spec/lib/api/v3/work_packages/work_package_representer_spec.rb @@ -164,7 +164,9 @@ context "when semantic work package ids are active", with_flag: { semantic_work_package_ids: true }, with_settings: { work_packages_identifier: "semantic" } do - it { is_expected.to be_json_eql(work_package.identifier.to_json).at_path("displayId") } + let(:work_package) { build_stubbed(:work_package, identifier: "PROJ-123", project: workspace) } + + it { is_expected.to be_json_eql("PROJ-123".to_json).at_path("displayId") } end context "when semantic work package ids are not active" do diff --git a/spec/models/work_package/semantic_identifier_spec.rb b/spec/models/work_package/semantic_identifier_spec.rb index 624ea6c13e9e..c538cff0ef4c 100644 --- a/spec/models/work_package/semantic_identifier_spec.rb +++ b/spec/models/work_package/semantic_identifier_spec.rb @@ -262,6 +262,16 @@ end end + context "when semantic mode is active but identifier is nil", + with_flag: { semantic_work_package_ids: true }, + with_settings: { work_packages_identifier: "semantic" } do + before { work_package.update_columns(identifier: nil) } + + it "falls back to the numeric id" do + expect(work_package.display_id).to eq(work_package.id) + end + end + context "when semantic mode is not active", with_flag: { semantic_work_package_ids: false } do it "returns the numeric id" do