From 35f05d5a5de327cdd2a226fe872439735f1e1008 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Fri, 10 Apr 2026 17:43:53 +0300 Subject: [PATCH 01/11] Adapt frontend work package show view for semantic identifiers Override WorkPackageBaseResource.id to read the API's displayId field, which is always present and returns the appropriate identifier for the current mode ("PROJ-42" in semantic, "123" in classic). This single change propagates to all WP views without per-view conditional logic. Also widen SPA route patterns from numeric-only to alphanumeric so semantic IDs like "PROJ-42" can be used in split view and BIM URLs, and remove parseInt coercion in hover sync since WP IDs may now be non-numeric strings. --- .../openproject-ifc-models.routes.ts | 2 +- .../resources/work-package-resource.spec.ts | 39 +++++++++++++++++++ .../hal/resources/work-package-resource.ts | 20 ++++++++++ .../wp-table/wp-table-hover-sync.ts | 4 +- .../routing/split-view-routes.template.ts | 4 +- 5 files changed, 64 insertions(+), 5 deletions(-) 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..b68385a829b7 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 @@ -99,7 +99,7 @@ export const IFC_ROUTES:Ng2StateDeclaration[] = [ }, { name: 'bim.partitioned.show', - url: '/show/{workPackageId:[0-9]+}', + url: '/show/{workPackageId:[A-Za-z0-9_-]+}', 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..544ac937ebf7 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,45 @@ describe('WorkPackage', () => { }); }); + describe('id getter', () => { + afterEach(() => { + source = undefined; + }); + + describe('when displayId is present (semantic mode)', () => { + beforeEach(() => { + source = { id: 42, displayId: 'PROJ-7' }; + createWorkPackage(); + }); + + it('should return the displayId', () => { + expect(workPackage.id).toEqual('PROJ-7'); + }); + }); + + describe('when displayId is present (classic mode)', () => { + beforeEach(() => { + source = { id: 42, displayId: '42' }; + createWorkPackage(); + }); + + it('should return the numeric displayId as string', () => { + expect(workPackage.id).toEqual('42'); + }); + }); + + describe('when displayId is absent (legacy API response)', () => { + beforeEach(() => { + source = { id: 42 }; + createWorkPackage(); + }); + + it('should fall back to the numeric id', () => { + expect(workPackage.id).toEqual('42'); + }); + }); + }); + 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..2c466ce045d7 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,26 @@ export class WorkPackageBaseResource extends HalResource { public subject:string; + /** + * Returns the user-facing work package identifier. + * + * The API always includes a `displayId` field: either `"PROJ-42"` in semantic + * mode or `"123"` in classic mode. This override means every consumer of `wp.id` + * (table rows, card views, URLs, cache keys) automatically gets the right value + * without per-view conditional logic. + * + * Note: `$source.id` (the numeric PK) is still available via `$source.id` and + * in `_links.self.href`, which always uses the numeric path. Only the + * user-facing identifier changes. + */ + public override get id():string|null { + if (this.$source.displayId) { + return this.$source.displayId.toString(); + } + + return super.id; + } + public updatedAt:Date; public lockVersion:number; diff --git a/frontend/src/app/features/work-packages/components/wp-table/wp-table-hover-sync.ts b/frontend/src/app/features/work-packages/components/wp-table/wp-table-hover-sync.ts index eff7497b1a0e..8199915a9d8d 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/wp-table-hover-sync.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/wp-table-hover-sync.ts @@ -72,8 +72,8 @@ export class WpTableHoverSync { this.removeOldAndAddNewHoverClass(parentTableRow, parentTimelineRow); } - private extractWorkPackageId(row:Element):number { - return parseInt(row.getAttribute('data-work-package-id')!); + private extractWorkPackageId(row:Element):string { + return row.getAttribute('data-work-package-id')!; } private removeOldAndAddNewHoverClass(parentTableRow:Element | null, parentTimelineRow:Element | null) { 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..d72aaf27dcea 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 @@ -66,7 +66,7 @@ export function makeSplitViewRoutes(baseRoute:string, return [ { name: `${routeName}.details`, - url: '/details/{workPackageId:[0-9]+}', + url: '/details/{workPackageId:[A-Za-z0-9_-]+}', redirectTo: (trans) => { const params = trans.params('to'); return { @@ -102,7 +102,7 @@ export function makeSplitViewRoutes(baseRoute:string, // Split create route { name: `${routeName}.new`, - url: '/create_new?{type:[0-9]+}&{parent_id:[0-9]+}', + url: '/create_new?{type:[0-9]+}&{parent_id:[A-Za-z0-9_-]+}', reloadOnSearch: false, params: { defaults: { From 5815943d263599b939ffcb248e61703cf8cfe639 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Fri, 10 Apr 2026 20:10:31 +0300 Subject: [PATCH 02/11] Use numeric PK for filters that expect database IDs Filters like resourceId, entityId, and parent map to integer database columns. Now that wp.id returns the display identifier ("KSTP-7"), these must use $source.id (the numeric PK) instead. --- .../wp-buttons/wp-share-button/wp-share-button.component.ts | 2 +- .../wp-relations/embedded/wp-relation-query.base.ts | 2 +- .../wp-relations-hierarchy.directive.ts | 2 +- .../routing/wp-view-base/work-package-single-view.base.ts | 4 +++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/features/work-packages/components/wp-buttons/wp-share-button/wp-share-button.component.ts b/frontend/src/app/features/work-packages/components/wp-buttons/wp-share-button/wp-share-button.component.ts index 5928d5d01e2c..6ff5ed52dbcb 100644 --- a/frontend/src/app/features/work-packages/components/wp-buttons/wp-share-button/wp-share-button.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-buttons/wp-share-button/wp-share-button.component.ts @@ -91,7 +91,7 @@ export class WorkPackageShareButtonComponent extends UntilDestroyedMixin impleme private countShares():Observable { const filters = new ApiV3FilterBuilder() .add('entityType', '=', ['WorkPackage']) - .add('entityId', '=', [this.workPackage.id!]); + .add('entityId', '=', [this.workPackage.$source.id!.toString()]); return this .apiV3Service diff --git a/frontend/src/app/features/work-packages/components/wp-relations/embedded/wp-relation-query.base.ts b/frontend/src/app/features/work-packages/components/wp-relations/embedded/wp-relation-query.base.ts index 890b01765322..aefd5d1cbbe7 100644 --- a/frontend/src/app/features/work-packages/components/wp-relations/embedded/wp-relation-query.base.ts +++ b/frontend/src/app/features/work-packages/components/wp-relations/embedded/wp-relation-query.base.ts @@ -104,7 +104,7 @@ export abstract class WorkPackageRelationQueryBase extends UntilDestroyedMixin { return this.queryUrlParamsHelper.buildV3GetQueryFromQueryResource( this.query, { valid_subset: true }, - { id: this.workPackage.id! }, + { id: this.workPackage.$source.id!.toString() }, ); } return this.query; diff --git a/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-hierarchy/wp-relations-hierarchy.directive.ts b/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-hierarchy/wp-relations-hierarchy.directive.ts index 1249d28bbe7c..5be188620b59 100644 --- a/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-hierarchy/wp-relations-hierarchy.directive.ts +++ b/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-hierarchy/wp-relations-hierarchy.directive.ts @@ -86,7 +86,7 @@ export class WorkPackageRelationsHierarchyComponent extends UntilDestroyedMixin this.canAddRelation = !!this.workPackage.addRelation; this.childrenQueryProps = { - filters: JSON.stringify([{ parent: { operator: '=', values: [this.workPackage.id] } }]), + filters: JSON.stringify([{ parent: { operator: '=', values: [this.workPackage.$source.id!.toString()] } }]), 'columns[]': ['id', 'type', 'subject', 'status'], showHierarchies: false, }; diff --git a/frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts b/frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts index 9e12b28fafbc..7b7b1ac14f5a 100644 --- a/frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts +++ b/frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts @@ -195,7 +195,9 @@ export abstract class WorkPackageSingleViewBase extends UntilDestroyedMixin { ); this.displayNotificationsButton$ = this.storeService.hasNotifications$; - this.storeService.setFilters(this.workPackage.id!); + // Use the numeric PK ($source.id) — the notification API's resourceId filter + // requires a database ID, not the user-facing display identifier. + this.storeService.setFilters(this.workPackage.$source.id!.toString()); // Set authorisation data this.authorisationService.initModelAuth('work_package', this.workPackage.$links); From 6c02cd1fea9871371ecea9f5cded302bcd3dc2f7 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Fri, 10 Apr 2026 20:43:08 +0300 Subject: [PATCH 03/11] Separate displayId from wp.id to fix cascading bugs Overriding wp.id to return the semantic identifier (e.g. "PROJ-42") broke cache keys, API filters, row rendering, and CSS selectors that all depend on the numeric PK. Instead, keep wp.id as the numeric PK and add two new properties: - displayId: returns the user-facing identifier ("PROJ-42" or "123") - displayIdWithHash: returns "#PROJ-42" or "#123" for UI display Also adds a COALESCE fallback in the SQL representer so work packages created before semantic mode was enabled still get a valid displayId. --- .../resources/work-package-resource.spec.ts | 38 ++++++++++++++++--- .../hal/resources/work-package-resource.ts | 33 +++++++++------- .../wp-share-button.component.ts | 2 +- .../embedded/wp-relation-query.base.ts | 2 +- .../wp-relations-hierarchy.directive.ts | 2 +- .../wp-table/wp-table-hover-sync.ts | 4 +- .../work-package-single-view.base.ts | 4 +- .../work_package_sql_representer.rb | 2 +- 8 files changed, 59 insertions(+), 28 deletions(-) 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 544ac937ebf7..99f8fcef22bf 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,7 +110,7 @@ describe('WorkPackage', () => { }); }); - describe('id getter', () => { + describe('displayId', () => { afterEach(() => { source = undefined; }); @@ -121,8 +121,12 @@ describe('WorkPackage', () => { createWorkPackage(); }); - it('should return the displayId', () => { - expect(workPackage.id).toEqual('PROJ-7'); + it('should return the semantic identifier', () => { + expect(workPackage.displayId).toEqual('PROJ-7'); + }); + + it('should not override the numeric id', () => { + expect(workPackage.id).toEqual('42'); }); }); @@ -133,7 +137,7 @@ describe('WorkPackage', () => { }); it('should return the numeric displayId as string', () => { - expect(workPackage.id).toEqual('42'); + expect(workPackage.displayId).toEqual('42'); }); }); @@ -144,11 +148,35 @@ describe('WorkPackage', () => { }); it('should fall back to the numeric id', () => { - expect(workPackage.id).toEqual('42'); + expect(workPackage.displayId).toEqual('42'); }); }); }); + describe('displayIdWithHash', () => { + afterEach(() => { + source = undefined; + }); + + it('should prefix displayId with # in semantic mode', () => { + source = { id: 42, displayId: 'PROJ-7' }; + createWorkPackage(); + expect(workPackage.displayIdWithHash).toEqual('#PROJ-7'); + }); + + it('should prefix displayId with # in classic mode', () => { + source = { id: 42, displayId: '42' }; + createWorkPackage(); + expect(workPackage.displayIdWithHash).toEqual('#42'); + }); + + it('should fall back to numeric id when displayId is absent', () => { + source = { id: 42 }; + createWorkPackage(); + expect(workPackage.displayIdWithHash).toEqual('#42'); + }); + }); + 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 2c466ce045d7..455445428146 100644 --- a/frontend/src/app/features/hal/resources/work-package-resource.ts +++ b/frontend/src/app/features/hal/resources/work-package-resource.ts @@ -127,22 +127,27 @@ export class WorkPackageBaseResource extends HalResource { /** * Returns the user-facing work package identifier. - * - * The API always includes a `displayId` field: either `"PROJ-42"` in semantic - * mode or `"123"` in classic mode. This override means every consumer of `wp.id` - * (table rows, card views, URLs, cache keys) automatically gets the right value - * without per-view conditional logic. - * - * Note: `$source.id` (the numeric PK) is still available via `$source.id` and - * in `_links.self.href`, which always uses the numeric path. Only the - * user-facing identifier changes. + * "PROJ-42" in semantic mode, "123" in classic mode. + * Falls back to numeric id when displayId is not in the API response. */ - public override get id():string|null { + public get displayId():string { if (this.$source.displayId) { return this.$source.displayId.toString(); } - return super.id; + return this.id?.toString() ?? ''; + } + + /** + * Returns the work package identifier formatted for display in the UI, + * always prefixed with `#`: `#123` in classic mode, `#PROJ-42` in + * semantic mode. + */ + public get displayIdWithHash():string { + const wpId = this.displayId; + if (!wpId) return ''; + + return `#${wpId}`; } public updatedAt:Date; @@ -190,7 +195,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 @@ -198,10 +203,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.displayIdWithHash})`; return `${this.truncatedSubject(truncateSubject)}${id}`; } diff --git a/frontend/src/app/features/work-packages/components/wp-buttons/wp-share-button/wp-share-button.component.ts b/frontend/src/app/features/work-packages/components/wp-buttons/wp-share-button/wp-share-button.component.ts index 6ff5ed52dbcb..5928d5d01e2c 100644 --- a/frontend/src/app/features/work-packages/components/wp-buttons/wp-share-button/wp-share-button.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-buttons/wp-share-button/wp-share-button.component.ts @@ -91,7 +91,7 @@ export class WorkPackageShareButtonComponent extends UntilDestroyedMixin impleme private countShares():Observable { const filters = new ApiV3FilterBuilder() .add('entityType', '=', ['WorkPackage']) - .add('entityId', '=', [this.workPackage.$source.id!.toString()]); + .add('entityId', '=', [this.workPackage.id!]); return this .apiV3Service diff --git a/frontend/src/app/features/work-packages/components/wp-relations/embedded/wp-relation-query.base.ts b/frontend/src/app/features/work-packages/components/wp-relations/embedded/wp-relation-query.base.ts index aefd5d1cbbe7..890b01765322 100644 --- a/frontend/src/app/features/work-packages/components/wp-relations/embedded/wp-relation-query.base.ts +++ b/frontend/src/app/features/work-packages/components/wp-relations/embedded/wp-relation-query.base.ts @@ -104,7 +104,7 @@ export abstract class WorkPackageRelationQueryBase extends UntilDestroyedMixin { return this.queryUrlParamsHelper.buildV3GetQueryFromQueryResource( this.query, { valid_subset: true }, - { id: this.workPackage.$source.id!.toString() }, + { id: this.workPackage.id! }, ); } return this.query; diff --git a/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-hierarchy/wp-relations-hierarchy.directive.ts b/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-hierarchy/wp-relations-hierarchy.directive.ts index 5be188620b59..1249d28bbe7c 100644 --- a/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-hierarchy/wp-relations-hierarchy.directive.ts +++ b/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-hierarchy/wp-relations-hierarchy.directive.ts @@ -86,7 +86,7 @@ export class WorkPackageRelationsHierarchyComponent extends UntilDestroyedMixin this.canAddRelation = !!this.workPackage.addRelation; this.childrenQueryProps = { - filters: JSON.stringify([{ parent: { operator: '=', values: [this.workPackage.$source.id!.toString()] } }]), + filters: JSON.stringify([{ parent: { operator: '=', values: [this.workPackage.id] } }]), 'columns[]': ['id', 'type', 'subject', 'status'], showHierarchies: false, }; diff --git a/frontend/src/app/features/work-packages/components/wp-table/wp-table-hover-sync.ts b/frontend/src/app/features/work-packages/components/wp-table/wp-table-hover-sync.ts index 8199915a9d8d..eff7497b1a0e 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/wp-table-hover-sync.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/wp-table-hover-sync.ts @@ -72,8 +72,8 @@ export class WpTableHoverSync { this.removeOldAndAddNewHoverClass(parentTableRow, parentTimelineRow); } - private extractWorkPackageId(row:Element):string { - return row.getAttribute('data-work-package-id')!; + private extractWorkPackageId(row:Element):number { + return parseInt(row.getAttribute('data-work-package-id')!); } private removeOldAndAddNewHoverClass(parentTableRow:Element | null, parentTimelineRow:Element | null) { diff --git a/frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts b/frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts index 7b7b1ac14f5a..9e12b28fafbc 100644 --- a/frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts +++ b/frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts @@ -195,9 +195,7 @@ export abstract class WorkPackageSingleViewBase extends UntilDestroyedMixin { ); this.displayNotificationsButton$ = this.storeService.hasNotifications$; - // Use the numeric PK ($source.id) — the notification API's resourceId filter - // requires a database ID, not the user-facing display identifier. - this.storeService.setFilters(this.workPackage.$source.id!.toString()); + this.storeService.setFilters(this.workPackage.id!); // Set authorisation data this.authorisationService.initModelAuth('work_package', this.workPackage.$links); 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 From f040a5a0ffeb1d9d961b6fe0c7c159cf5df35c91 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Fri, 10 Apr 2026 21:03:50 +0300 Subject: [PATCH 04/11] Extract WP_ID_URL_PATTERN for frontend route definitions Centralise the URL segment pattern that matches work package identifiers into a named constant so route definitions reference a single source of truth instead of duplicating the regex. --- .../bim/ifc_models/openproject-ifc-models.routes.ts | 3 ++- .../work-packages/routing/split-view-routes.template.ts | 5 +++-- .../src/app/shared/helpers/work-package-id-pattern.ts | 8 ++++++++ 3 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 frontend/src/app/shared/helpers/work-package-id-pattern.ts 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 b68385a829b7..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:[A-Za-z0-9_-]+}', + url: `/show/{workPackageId:${WP_ID_URL_PATTERN}}`, data: { baseRoute: 'bim.partitioned.list', partition: '-left-only', 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 d72aaf27dcea..c6c596d719e9 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:[A-Za-z0-9_-]+}', + url: `/details/{workPackageId:${WP_ID_URL_PATTERN}}`, redirectTo: (trans) => { const params = trans.params('to'); return { @@ -102,7 +103,7 @@ export function makeSplitViewRoutes(baseRoute:string, // Split create route { name: `${routeName}.new`, - url: '/create_new?{type:[0-9]+}&{parent_id:[A-Za-z0-9_-]+}', + url: `/create_new?{type:[0-9]+}&{parent_id:${WP_ID_URL_PATTERN}}`, reloadOnSearch: false, params: { defaults: { 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..00a2336ad5e4 --- /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 = '[A-Za-z0-9_-]+'; From 9c757cf1948f92a39ec45995bebbb42902b5e513 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Tue, 14 Apr 2026 00:04:06 +0300 Subject: [PATCH 05/11] Keep parent_id route constraint numeric-only parent_id is consumed by parseInt() in wp-create.component.ts, which returns NaN for semantic identifiers like "PROJ-42". This silently breaks parent assignment when creating child work packages. Revert the route constraint back to [0-9]+ since parent_id is a machine-level concern used in API calls, not a display identifier. Add a feature spec with semantic mode enabled to verify the URL uses the numeric PK and the parent relationship is set correctly. --- .../routing/split-view-routes.template.ts | 2 +- .../context_menu_shared_examples.rb | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) 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 c6c596d719e9..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 @@ -103,7 +103,7 @@ export function makeSplitViewRoutes(baseRoute:string, // Split create route { name: `${routeName}.new`, - url: `/create_new?{type:[0-9]+}&{parent_id:${WP_ID_URL_PATTERN}}`, + url: '/create_new?{type:[0-9]+}&{parent_id:[0-9]+}', reloadOnSearch: false, params: { defaults: { 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 From cc6f184e4670ac767f1b5d913ecae75d0a0a2d8a Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Tue, 14 Apr 2026 00:04:17 +0300 Subject: [PATCH 06/11] Tighten WP_ID_URL_PATTERN to match backend route constraint The frontend pattern was [A-Za-z0-9_-]+ which accepted invalid identifiers like "---", "abc", or "42abc". Tighten it to match the backend ID_ROUTE_CONSTRAINT: either a pure numeric ID or a semantic identifier starting with a letter and ending with a hyphen followed by digits (e.g. "PROJ-42"). --- frontend/src/app/shared/helpers/work-package-id-pattern.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/shared/helpers/work-package-id-pattern.ts b/frontend/src/app/shared/helpers/work-package-id-pattern.ts index 00a2336ad5e4..b189e59120ad 100644 --- a/frontend/src/app/shared/helpers/work-package-id-pattern.ts +++ b/frontend/src/app/shared/helpers/work-package-id-pattern.ts @@ -5,4 +5,4 @@ * 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 = '[A-Za-z0-9_-]+'; +export const WP_ID_URL_PATTERN = '\\d+|[A-Za-z][A-Za-z0-9_]*-\\d+'; From e12be14e26ceef84cbaaedb246926f69d5941459 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Tue, 14 Apr 2026 00:05:00 +0300 Subject: [PATCH 07/11] Add subjectWithId specs for semantic display identifiers Cover the changed behavior where subjectWithId now delegates to displayIdWithHash instead of inlining the numeric id. Tests verify semantic mode output (#PROJ-7), classic mode (#42), and no suffix for new resources. --- .../resources/work-package-resource.spec.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) 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 99f8fcef22bf..0238f1c05af7 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 @@ -161,22 +161,52 @@ describe('WorkPackage', () => { it('should prefix displayId with # in semantic mode', () => { source = { id: 42, displayId: 'PROJ-7' }; createWorkPackage(); + expect(workPackage.displayIdWithHash).toEqual('#PROJ-7'); }); it('should prefix displayId with # in classic mode', () => { source = { id: 42, displayId: '42' }; createWorkPackage(); + expect(workPackage.displayIdWithHash).toEqual('#42'); }); it('should fall back to numeric id when displayId is absent', () => { source = { id: 42 }; createWorkPackage(); + expect(workPackage.displayIdWithHash).toEqual('#42'); }); }); + describe('subjectWithId', () => { + afterEach(() => { + source = undefined; + }); + + it('should include semantic displayId 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 numeric displayId 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); From 61111f02b87de52a0ce7a7777fd86c691c18f927 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Tue, 14 Apr 2026 00:05:09 +0300 Subject: [PATCH 08/11] Fix display_id to fall back to numeric id when identifier is nil In semantic mode, work packages created before the feature was enabled have a nil identifier column. Previously display_id returned nil for these, causing the Ruby representer to serialize displayId as null while the SQL representer used COALESCE to return the numeric id. Use identifier.presence to fall back to the numeric id, aligning both representer paths. --- app/models/work_package/semantic_identifier.rb | 4 +++- .../v3/work_packages/work_package_representer_spec.rb | 4 +++- spec/models/work_package/semantic_identifier_spec.rb | 10 ++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) 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/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 From ce7533baea233d2e8b5ff753f337fb4ce622b5cb Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 15 Apr 2026 19:31:26 +0300 Subject: [PATCH 09/11] Rename displayIdWithHash to formattedId with conditional hash prefix Semantic identifiers (e.g. PROJ-42) are self-describing and should not be prefixed with #. Only classic numeric IDs retain the hash prefix (e.g. #42). This matches Jira's convention and PR review feedback. The detection uses a simple regex check: if the displayId contains any letter, it's semantic (no hash); otherwise it's classic (hash prefix). --- .../resources/work-package-resource.spec.ts | 20 +++++++++---------- .../hal/resources/work-package-resource.ts | 16 +++++++-------- 2 files changed, 18 insertions(+), 18 deletions(-) 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 0238f1c05af7..085511d6082a 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 @@ -153,30 +153,30 @@ describe('WorkPackage', () => { }); }); - describe('displayIdWithHash', () => { + describe('formattedId', () => { afterEach(() => { source = undefined; }); - it('should prefix displayId with # in semantic mode', () => { + it('should return semantic identifier without hash prefix', () => { source = { id: 42, displayId: 'PROJ-7' }; createWorkPackage(); - expect(workPackage.displayIdWithHash).toEqual('#PROJ-7'); + expect(workPackage.formattedId).toEqual('PROJ-7'); }); - it('should prefix displayId with # in classic mode', () => { + it('should prefix numeric id with # in classic mode', () => { source = { id: 42, displayId: '42' }; createWorkPackage(); - expect(workPackage.displayIdWithHash).toEqual('#42'); + expect(workPackage.formattedId).toEqual('#42'); }); - it('should fall back to numeric id when displayId is absent', () => { + it('should fall back to hash-prefixed numeric id when displayId is absent', () => { source = { id: 42 }; createWorkPackage(); - expect(workPackage.displayIdWithHash).toEqual('#42'); + expect(workPackage.formattedId).toEqual('#42'); }); }); @@ -185,14 +185,14 @@ describe('WorkPackage', () => { source = undefined; }); - it('should include semantic displayId in parentheses', () => { + 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)'); + expect(workPackage.subjectWithId()).toEqual('Fix the bug (PROJ-7)'); }); - it('should include numeric displayId in classic mode', () => { + it('should include hash-prefixed numeric id in classic mode', () => { source = { id: 42, displayId: '42', subject: 'Fix the bug' }; 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 455445428146..e4e4734558ec 100644 --- a/frontend/src/app/features/hal/resources/work-package-resource.ts +++ b/frontend/src/app/features/hal/resources/work-package-resource.ts @@ -139,15 +139,15 @@ export class WorkPackageBaseResource extends HalResource { } /** - * Returns the work package identifier formatted for display in the UI, - * always prefixed with `#`: `#123` in classic mode, `#PROJ-42` in - * semantic mode. + * 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 displayIdWithHash():string { + public get formattedId():string { const wpId = this.displayId; if (!wpId) return ''; - return `#${wpId}`; + return /[A-Za-z]/.test(wpId) ? wpId : `#${wpId}`; } public updatedAt:Date; @@ -195,7 +195,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 @@ -203,10 +203,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.displayIdWithHash})`; + const id = isNewResource(this) ? '' : ` (${this.formattedId})`; return `${this.truncatedSubject(truncateSubject)}${id}`; } From 26f87c6d8f66ac5fff2455182ef3b7609f1c8ea2 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 15 Apr 2026 19:35:09 +0300 Subject: [PATCH 10/11] Simplify displayId getter now that API field is mandatory PR #22710 made displayId a required field in all API v3 work package responses. The if/else fallback to this.id and corresponding legacy specs are now dead code. Collapse to a null-coalescing one-liner (still safe against stale cache during rolling deploys) and remove specs that tested the absent-field scenario. --- .../resources/work-package-resource.spec.ts | 20 ++----------------- .../hal/resources/work-package-resource.ts | 11 ++-------- 2 files changed, 4 insertions(+), 27 deletions(-) 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 085511d6082a..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 @@ -141,17 +141,7 @@ describe('WorkPackage', () => { }); }); - describe('when displayId is absent (legacy API response)', () => { - beforeEach(() => { - source = { id: 42 }; - createWorkPackage(); - }); - - it('should fall back to the numeric id', () => { - expect(workPackage.displayId).toEqual('42'); - }); - }); - }); +}); describe('formattedId', () => { afterEach(() => { @@ -172,13 +162,7 @@ describe('WorkPackage', () => { expect(workPackage.formattedId).toEqual('#42'); }); - it('should fall back to hash-prefixed numeric id when displayId is absent', () => { - source = { id: 42 }; - createWorkPackage(); - - expect(workPackage.formattedId).toEqual('#42'); - }); - }); +}); describe('subjectWithId', () => { afterEach(() => { 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 e4e4734558ec..1210ce87e1b2 100644 --- a/frontend/src/app/features/hal/resources/work-package-resource.ts +++ b/frontend/src/app/features/hal/resources/work-package-resource.ts @@ -127,15 +127,10 @@ export class WorkPackageBaseResource extends HalResource { /** * Returns the user-facing work package identifier. - * "PROJ-42" in semantic mode, "123" in classic mode. - * Falls back to numeric id when displayId is not in the API response. + * "PROJ-42" in semantic mode, "42" in classic mode. */ public get displayId():string { - if (this.$source.displayId) { - return this.$source.displayId.toString(); - } - - return this.id?.toString() ?? ''; + return this.$source.displayId?.toString() ?? this.id?.toString() ?? ''; } /** @@ -145,8 +140,6 @@ export class WorkPackageBaseResource extends HalResource { */ public get formattedId():string { const wpId = this.displayId; - if (!wpId) return ''; - return /[A-Za-z]/.test(wpId) ? wpId : `#${wpId}`; } From b29c528f7039e763732a612af7451e1b77736bef Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 15 Apr 2026 20:01:20 +0300 Subject: [PATCH 11/11] Fix show view idLabel to use formattedId instead of hard-coded #id The idLabel getter was bypassing formattedId entirely, always rendering the numeric PK with a # prefix. In semantic mode this showed #9720 instead of KSTP-1. --- .../components/wp-single-view/wp-single-view.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 {