From 4d228eff9c9ad5017bc161a8c6b9dd05c8fbd75a Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Mon, 13 Apr 2026 14:24:10 +0300 Subject: [PATCH 1/7] Use displayId in work package URLs for semantic identifier routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes already accept semantic identifiers (e.g. PROJ-7) via WP_ID_URL_PATTERN, but all frontend-generated links still used numeric PKs. This wires displayId into every navigation path so the browser URL bar shows the semantic form when available. Key design decision: data-work-package-id attributes on elements stay numeric — the selection/hover system is keyed by PK. Only the href gets the semantic ID via a new optional routingId parameter on UiStateLinkBuilder. Changes span table links (ID column, linked WP fields, details action), navigation handlers (list view, embedded tables, boards, BCF, calendar), breadcrumbs, tabs, hierarchy, single view, context menu, quickinfo macro, and post-creation redirect. API-only paths (time entries, share, hover cards, progress modal) are deliberately left with numeric IDs — they never appear in the address bar. --- .../bim/ifc_models/bcf/list/bcf-list.component.ts | 8 +++++++- .../board/board-list/board-list.component.ts | 15 ++++++++++++--- .../calendar/op-work-packages-calendar.service.ts | 13 +++++++++++-- .../calendar/wp-calendar/wp-calendar.component.ts | 7 ++++--- .../components/wp-new/wp-create.component.ts | 5 +++-- .../embedded/wp-embedded-table.component.ts | 14 ++++++++++++-- .../wp-list-view/wp-list-view.component.ts | 13 +++++++++++-- .../wp-view-context-menu.directive.ts | 6 ++++-- 8 files changed, 64 insertions(+), 17 deletions(-) diff --git a/frontend/src/app/features/bim/ifc_models/bcf/list/bcf-list.component.ts b/frontend/src/app/features/bim/ifc_models/bcf/list/bcf-list.component.ts index 6593200dd8f0..e2adef37db53 100644 --- a/frontend/src/app/features/bim/ifc_models/bcf/list/bcf-list.component.ts +++ b/frontend/src/app/features/bim/ifc_models/bcf/list/bcf-list.component.ts @@ -129,8 +129,14 @@ export class BcfListComponent extends WorkPackageListViewComponent implements Un : 'bim.partitioned.show'; // Passing the card param to the new state because the router doesn't keep // it when going to 'bim.partitioned.show' - const params = { workPackageId, cards, focus }; + const routingId = this.resolveRoutingId(workPackageId); + const params = { workPackageId: routingId, cards, focus }; void this.$state.go(stateToGo, params); } + + private resolveRoutingId(workPackageId:string):string { + const wp = this.states.workPackages.get(workPackageId)?.value; + return wp?.displayId ?? workPackageId; + } } diff --git a/frontend/src/app/features/boards/board/board-list/board-list.component.ts b/frontend/src/app/features/boards/board/board-list/board-list.component.ts index baa4f648b35a..680e309e0847 100644 --- a/frontend/src/app/features/boards/board/board-list/board-list.component.ts +++ b/frontend/src/app/features/boards/board/board-list/board-list.component.ts @@ -26,6 +26,7 @@ import { AuthorisationService } from 'core-app/core/model-auth/model-auth.servic import { Highlighting } from 'core-app/features/work-packages/components/wp-fast-table/builders/highlighting/highlighting.functions'; import { WorkPackageCardViewComponent } from 'core-app/features/work-packages/components/wp-card-view/wp-card-view.component'; import { WorkPackageStatesInitializationService } from 'core-app/features/work-packages/components/wp-list/wp-states-initialization.service'; +import { States } from 'core-app/core/states/states.service'; import { BoardService } from 'core-app/features/boards/board/board.service'; import { HalResourceEditingService } from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service'; import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service'; @@ -175,6 +176,7 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni readonly keepTab:KeepTabService, readonly currentProject:CurrentProjectService, readonly pathHelper:PathHelperService, + readonly states:States, ) { super(I18n, injector); } @@ -488,17 +490,19 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni openFullViewOnDoubleClick(event:{ workPackageId:string, double:boolean }) { if (event.double) { + const routingId = this.resolveRoutingId(event.workPackageId); const projectIdentifier = this.currentProject.identifier; - const link = this.pathHelper.genericWorkPackagePath(projectIdentifier, event.workPackageId) + window.location.search; + const link = this.pathHelper.genericWorkPackagePath(projectIdentifier, routingId) + window.location.search; Turbo.visit(link, { action: 'advance' }); } } openStateLink(event:{ workPackageId:string; requestedState:string }) { + const routingId = this.resolveRoutingId(event.workPackageId); if (event.requestedState === 'split') { - this.goToSplitView(event.workPackageId); + this.goToSplitView(routingId); } else { - this.keepTab.goCurrentShowState(event.workPackageId); + this.keepTab.goCurrentShowState(routingId); } } @@ -509,6 +513,11 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni Turbo.visit(link, { frame: 'content-bodyRight', action: 'advance' }); } + private resolveRoutingId(workPackageId:string):string { + const wp = this.states.workPackages.get(workPackageId)?.value; + return wp?.displayId ?? workPackageId; + } + private schema(workPackage:WorkPackageResource) { return this.schemaCache.of(workPackage); } diff --git a/frontend/src/app/features/calendar/op-work-packages-calendar.service.ts b/frontend/src/app/features/calendar/op-work-packages-calendar.service.ts index 86dbd0c0d09d..d5dfb0c17b7a 100644 --- a/frontend/src/app/features/calendar/op-work-packages-calendar.service.ts +++ b/frontend/src/app/features/calendar/op-work-packages-calendar.service.ts @@ -53,6 +53,7 @@ import { uiStateLinkClass, } from 'core-app/features/work-packages/components/wp-fast-table/builders/ui-state-link-builder'; import { debugLog } from 'core-app/shared/helpers/debug_output'; +import { States } from 'core-app/core/states/states.service'; import { WorkPackageViewContextMenu, } from 'core-app/shared/components/op-context-menu/wp-context-menu/wp-view-context-menu.directive'; @@ -112,6 +113,7 @@ export class OpWorkPackagesCalendarService extends UntilDestroyedMixin { readonly calendarService:OpCalendarService, readonly weekdayService:WeekdayService, readonly dayService:DayResourceService, + readonly states:States, ) { super(); } @@ -287,21 +289,28 @@ export class OpWorkPackagesCalendarService extends UntilDestroyedMixin { return; } + const routingId = this.resolveRoutingId(id); void this.$state.go( `${splitViewRoute(this.$state)}.tabs`, - { workPackageId: id, tabIdentifier: 'overview' }, + { workPackageId: routingId, tabIdentifier: 'overview' }, ); } public openFullView(id:string):void { this.wpTableSelection.setSelection(id, -1); + const routingId = this.resolveRoutingId(id); void this.$state.go( 'work-packages.show', - { workPackageId: id }, + { workPackageId: routingId }, ); } + private resolveRoutingId(workPackageId:string):string { + const wp = this.states.workPackages.get(workPackageId)?.value; + return wp?.displayId ?? workPackageId; + } + public onCardClicked({ workPackageId, event }:{ workPackageId:string, event:MouseEvent }):void { if (isClickedWithModifier(event)) { return; diff --git a/frontend/src/app/features/calendar/wp-calendar/wp-calendar.component.ts b/frontend/src/app/features/calendar/wp-calendar/wp-calendar.component.ts index e0f6f2f5f2cf..d1c36055438a 100644 --- a/frontend/src/app/features/calendar/wp-calendar/wp-calendar.component.ts +++ b/frontend/src/app/features/calendar/wp-calendar/wp-calendar.component.ts @@ -342,15 +342,16 @@ export class WorkPackagesCalendarComponent extends UntilDestroyedMixin implement } if (evt.event.extendedProps.workPackage) { - const workPackageId = (evt.event.extendedProps.workPackage as WorkPackageResource).id!; + const wp = evt.event.extendedProps.workPackage as WorkPackageResource; // Currently the calendar widget is shown on multiple pages, // but only the calendar module itself is a partitioned query space which can deal with a split screen request if (this.$state.includes('calendar')) { - this.workPackagesCalendar.openSplitView(workPackageId); + this.workPackagesCalendar.openSplitView(wp.id!); } else { + const routingId = wp.displayId ?? wp.id!; void this.$state.go( 'work-packages.show', - { workPackageId }, + { workPackageId: routingId }, ); } } diff --git a/frontend/src/app/features/work-packages/components/wp-new/wp-create.component.ts b/frontend/src/app/features/work-packages/components/wp-new/wp-create.component.ts index 77a0449e7f83..c4c920219448 100644 --- a/frontend/src/app/features/work-packages/components/wp-new/wp-create.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-new/wp-create.component.ts @@ -130,15 +130,16 @@ export class WorkPackageCreateComponent extends UntilDestroyedMixin implements O this.editForm?.cancel(false); + const routingId = savedResource.displayId ?? savedResource.id!; if(this.routedFromAngular && this.successState) { - this.$state.go(this.successState, { workPackageId: savedResource.id }) + this.$state.go(this.successState, { workPackageId: routingId }) .then(() => { this.wpViewFocus.updateFocus(savedResource.id!); this.notificationService.showSave(savedResource, isInitial); }); } else { window.OpenProject.pageState = 'submitted'; - Turbo.visit(this.pathHelper.projectWorkPackagePath(savedResource.project.identifier, savedResource.id!) + window.location.search); + Turbo.visit(this.pathHelper.projectWorkPackagePath(savedResource.project.identifier, routingId) + window.location.search); } } diff --git a/frontend/src/app/features/work-packages/components/wp-table/embedded/wp-embedded-table.component.ts b/frontend/src/app/features/work-packages/components/wp-table/embedded/wp-embedded-table.component.ts index d2306ca88d41..b90d118e90c5 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/embedded/wp-embedded-table.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/embedded/wp-embedded-table.component.ts @@ -23,6 +23,7 @@ import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decora import { KeepTabService, } from 'core-app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service'; +import { States } from 'core-app/core/states/states.service'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; import { firstValueFrom } from 'rxjs'; import { QueryRequestParams } from 'core-app/features/work-packages/components/wp-query/url-params-helper'; @@ -63,6 +64,8 @@ export class WorkPackageEmbeddedTableComponent extends WorkPackageEmbeddedBaseCo @InjectField() keepTab:KeepTabService; + @InjectField() states:States; + // Cache the form promise private formPromise:Promise|undefined; @@ -190,15 +193,17 @@ export class WorkPackageEmbeddedTableComponent extends WorkPackageEmbeddedBaseCo handleWorkPackageClicked(event:{ workPackageId:string; double:boolean }) { if (event.double) { + const routingId = this.resolveRoutingId(event.workPackageId); const projectIdentifier = this.currentProject.identifier; - const link = this.pathHelper.genericWorkPackagePath(projectIdentifier, event.workPackageId) + window.location.search; + const link = this.pathHelper.genericWorkPackagePath(projectIdentifier, routingId) + window.location.search; Turbo.visit(link, { action: 'advance' }); } } openStateLink(event:{ workPackageId:string; requestedState:'show'|'split' }) { + const routingId = this.resolveRoutingId(event.workPackageId); const params = { - workPackageId: event.workPackageId, + workPackageId: routingId, focus: true, }; @@ -208,4 +213,9 @@ export class WorkPackageEmbeddedTableComponent extends WorkPackageEmbeddedBaseCo this.keepTab.goCurrentShowState(params.workPackageId); } } + + private resolveRoutingId(workPackageId:string):string { + const wp = this.states.workPackages.get(workPackageId)?.value; + return wp?.displayId ?? workPackageId; + } } diff --git a/frontend/src/app/features/work-packages/routing/wp-list-view/wp-list-view.component.ts b/frontend/src/app/features/work-packages/routing/wp-list-view/wp-list-view.component.ts index 913a7fa4b576..da003c7def8a 100644 --- a/frontend/src/app/features/work-packages/routing/wp-list-view/wp-list-view.component.ts +++ b/frontend/src/app/features/work-packages/routing/wp-list-view/wp-list-view.component.ts @@ -57,6 +57,7 @@ import { KeepTabService } from 'core-app/features/work-packages/components/wp-si import { WorkPackageViewBaselineService } from '../wp-view-base/view-services/wp-view-baseline.service'; import { combineLatest } from 'rxjs'; import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; +import { States } from 'core-app/core/states/states.service'; @Component({ selector: 'wp-list-view', @@ -85,6 +86,7 @@ export class WorkPackageListViewComponent extends UntilDestroyedMixin implements readonly elementRef = inject>(ElementRef); readonly wpTableBaseline = inject(WorkPackageViewBaselineService); readonly pathHelper = inject(PathHelperService); + readonly states = inject(States); text = { jump_to_pagination: this.I18n.t('js.work_packages.jump_marks.pagination'), @@ -178,8 +180,9 @@ export class WorkPackageListViewComponent extends UntilDestroyedMixin implements } openStateLink(event:{ workPackageId:string; requestedState:'show'|'split' }) { + const routingId = this.resolveRoutingId(event.workPackageId); const params = { - workPackageId: event.workPackageId, + workPackageId: routingId, focus: true, }; @@ -203,7 +206,13 @@ export class WorkPackageListViewComponent extends UntilDestroyedMixin implements } private openInFullView(workPackageId:string) { + const routingId = this.resolveRoutingId(workPackageId); const projectIdentifier = this.CurrentProject.identifier; - window.location.href = this.pathHelper.genericWorkPackagePath(projectIdentifier, workPackageId) + window.location.search; + window.location.href = this.pathHelper.genericWorkPackagePath(projectIdentifier, routingId) + window.location.search; + } + + private resolveRoutingId(workPackageId:string):string { + const wp = this.states.workPackages.get(workPackageId)?.value; + return wp?.displayId ?? workPackageId; } } diff --git a/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-view-context-menu.directive.ts b/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-view-context-menu.directive.ts index f4e6953a8fe9..2392f5b3af83 100644 --- a/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-view-context-menu.directive.ts +++ b/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-view-context-menu.directive.ts @@ -137,12 +137,14 @@ export class WorkPackageViewContextMenu extends OpContextMenuHandler { void this.turboRequests.requestStream(String(link)); break; - case 'relations': + case 'relations': { + const routingId = this.workPackage.displayId ?? this.workPackageId; void this.$state.go( `${splitViewRoute(this.$state)}.tabs`, - { workPackageId: this.workPackageId, tabIdentifier: 'relations' }, + { workPackageId: routingId, tabIdentifier: 'relations' }, ); break; + } default: window.location.href = link!; From 950fdd003f676a17a1b6522665bce2d798950d6d Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Mon, 13 Apr 2026 15:56:08 +0300 Subject: [PATCH 2/7] Extract shared resolveRoutingId helper Replaces 5 identical private resolveRoutingId methods with a shared utility function that accepts States as a parameter. --- .../bim/ifc_models/bcf/list/bcf-list.component.ts | 4 ++-- .../boards/board/board-list/board-list.component.ts | 4 ++-- .../calendar/op-work-packages-calendar.service.ts | 4 ++-- .../embedded/wp-embedded-table.component.ts | 4 ++-- .../work-packages/helpers/resolve-routing-id.ts | 13 +++++++++++++ .../routing/wp-list-view/wp-list-view.component.ts | 4 ++-- 6 files changed, 23 insertions(+), 10 deletions(-) create mode 100644 frontend/src/app/features/work-packages/helpers/resolve-routing-id.ts diff --git a/frontend/src/app/features/bim/ifc_models/bcf/list/bcf-list.component.ts b/frontend/src/app/features/bim/ifc_models/bcf/list/bcf-list.component.ts index e2adef37db53..9d7229562934 100644 --- a/frontend/src/app/features/bim/ifc_models/bcf/list/bcf-list.component.ts +++ b/frontend/src/app/features/bim/ifc_models/bcf/list/bcf-list.component.ts @@ -31,6 +31,7 @@ import { } from '@angular/core'; import { UIRouterGlobals } from '@uirouter/core'; import { States } from 'core-app/core/states/states.service'; +import { resolveRoutingId } from 'core-app/features/work-packages/helpers/resolve-routing-id'; import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; import { DragAndDropService } from 'core-app/shared/helpers/drag-and-drop/drag-and-drop.service'; @@ -136,7 +137,6 @@ export class BcfListComponent extends WorkPackageListViewComponent implements Un } private resolveRoutingId(workPackageId:string):string { - const wp = this.states.workPackages.get(workPackageId)?.value; - return wp?.displayId ?? workPackageId; + return resolveRoutingId(this.states, workPackageId); } } diff --git a/frontend/src/app/features/boards/board/board-list/board-list.component.ts b/frontend/src/app/features/boards/board/board-list/board-list.component.ts index 680e309e0847..535899192a0c 100644 --- a/frontend/src/app/features/boards/board/board-list/board-list.component.ts +++ b/frontend/src/app/features/boards/board/board-list/board-list.component.ts @@ -27,6 +27,7 @@ import { Highlighting } from 'core-app/features/work-packages/components/wp-fast import { WorkPackageCardViewComponent } from 'core-app/features/work-packages/components/wp-card-view/wp-card-view.component'; import { WorkPackageStatesInitializationService } from 'core-app/features/work-packages/components/wp-list/wp-states-initialization.service'; import { States } from 'core-app/core/states/states.service'; +import { resolveRoutingId } from 'core-app/features/work-packages/helpers/resolve-routing-id'; import { BoardService } from 'core-app/features/boards/board/board.service'; import { HalResourceEditingService } from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service'; import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service'; @@ -514,8 +515,7 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni } private resolveRoutingId(workPackageId:string):string { - const wp = this.states.workPackages.get(workPackageId)?.value; - return wp?.displayId ?? workPackageId; + return resolveRoutingId(this.states, workPackageId); } private schema(workPackage:WorkPackageResource) { diff --git a/frontend/src/app/features/calendar/op-work-packages-calendar.service.ts b/frontend/src/app/features/calendar/op-work-packages-calendar.service.ts index d5dfb0c17b7a..8717cbde8231 100644 --- a/frontend/src/app/features/calendar/op-work-packages-calendar.service.ts +++ b/frontend/src/app/features/calendar/op-work-packages-calendar.service.ts @@ -54,6 +54,7 @@ import { } from 'core-app/features/work-packages/components/wp-fast-table/builders/ui-state-link-builder'; import { debugLog } from 'core-app/shared/helpers/debug_output'; import { States } from 'core-app/core/states/states.service'; +import { resolveRoutingId } from 'core-app/features/work-packages/helpers/resolve-routing-id'; import { WorkPackageViewContextMenu, } from 'core-app/shared/components/op-context-menu/wp-context-menu/wp-view-context-menu.directive'; @@ -307,8 +308,7 @@ export class OpWorkPackagesCalendarService extends UntilDestroyedMixin { } private resolveRoutingId(workPackageId:string):string { - const wp = this.states.workPackages.get(workPackageId)?.value; - return wp?.displayId ?? workPackageId; + return resolveRoutingId(this.states, workPackageId); } public onCardClicked({ workPackageId, event }:{ workPackageId:string, event:MouseEvent }):void { diff --git a/frontend/src/app/features/work-packages/components/wp-table/embedded/wp-embedded-table.component.ts b/frontend/src/app/features/work-packages/components/wp-table/embedded/wp-embedded-table.component.ts index b90d118e90c5..c2af853f7882 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/embedded/wp-embedded-table.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/embedded/wp-embedded-table.component.ts @@ -24,6 +24,7 @@ import { KeepTabService, } from 'core-app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service'; import { States } from 'core-app/core/states/states.service'; +import { resolveRoutingId } from 'core-app/features/work-packages/helpers/resolve-routing-id'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; import { firstValueFrom } from 'rxjs'; import { QueryRequestParams } from 'core-app/features/work-packages/components/wp-query/url-params-helper'; @@ -215,7 +216,6 @@ export class WorkPackageEmbeddedTableComponent extends WorkPackageEmbeddedBaseCo } private resolveRoutingId(workPackageId:string):string { - const wp = this.states.workPackages.get(workPackageId)?.value; - return wp?.displayId ?? workPackageId; + return resolveRoutingId(this.states, workPackageId); } } diff --git a/frontend/src/app/features/work-packages/helpers/resolve-routing-id.ts b/frontend/src/app/features/work-packages/helpers/resolve-routing-id.ts new file mode 100644 index 000000000000..2b054df138c4 --- /dev/null +++ b/frontend/src/app/features/work-packages/helpers/resolve-routing-id.ts @@ -0,0 +1,13 @@ +import { States } from 'core-app/core/states/states.service'; + +/** + * Resolve a numeric work package ID to its semantic routing ID (e.g. "PROJ-42"). + * Falls back to the input ID if the WP is not in cache or has no displayId. + * + * Used in navigation handlers where only the numeric PK is available from + * data-work-package-id attributes, but the URL should show the semantic form. + */ +export function resolveRoutingId(states:States, workPackageId:string):string { + const wp = states.workPackages.get(workPackageId)?.value; + return wp?.displayId ?? workPackageId; +} diff --git a/frontend/src/app/features/work-packages/routing/wp-list-view/wp-list-view.component.ts b/frontend/src/app/features/work-packages/routing/wp-list-view/wp-list-view.component.ts index da003c7def8a..e8ccfab1c3ba 100644 --- a/frontend/src/app/features/work-packages/routing/wp-list-view/wp-list-view.component.ts +++ b/frontend/src/app/features/work-packages/routing/wp-list-view/wp-list-view.component.ts @@ -58,6 +58,7 @@ import { WorkPackageViewBaselineService } from '../wp-view-base/view-services/wp import { combineLatest } from 'rxjs'; import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; import { States } from 'core-app/core/states/states.service'; +import { resolveRoutingId } from 'core-app/features/work-packages/helpers/resolve-routing-id'; @Component({ selector: 'wp-list-view', @@ -212,7 +213,6 @@ export class WorkPackageListViewComponent extends UntilDestroyedMixin implements } private resolveRoutingId(workPackageId:string):string { - const wp = this.states.workPackages.get(workPackageId)?.value; - return wp?.displayId ?? workPackageId; + return resolveRoutingId(this.states, workPackageId); } } From 4b285cb19a10596b89067ed7efb9e2388a88af8d Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Mon, 13 Apr 2026 15:57:29 +0300 Subject: [PATCH 3/7] Fix split view focus/selection using numeric ID instead of route param Defer focus and selection to init() (called after WP loads) so we use this.workPackage.id (always numeric) instead of the route param which may be a semantic identifier like "PROJ-7". --- .../wp-split-view/wp-split-view.component.ts | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.component.ts b/frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.component.ts index eb2c1121b9b7..1ba966071f10 100644 --- a/frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.component.ts +++ b/frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.component.ts @@ -101,19 +101,13 @@ export class WorkPackageSplitViewComponent extends WorkPackageSingleViewBase imp ngOnInit():void { this.observeWorkPackage(); - const wpId = (this.$state.params.workPackageId || this.workPackageId) as string; - this.wpTableFocus.updateFocus(wpId, false); - - if (this.wpTableSelection.isEmpty) { - this.wpTableSelection.setRowState(wpId, true); - } - this.wpTableFocus.whenChanged() .pipe( this.untilDestroyed(), ) .subscribe((newId) => { - const idSame = wpId.toString() === newId.toString(); + const currentId = this.workPackage?.id ?? this.workPackageId; + const idSame = currentId.toString() === newId.toString(); if (!idSame && this.$state.includes(`${this.baseRoute}.details`)) { this.$state.go( (this.$state.current.name!), @@ -121,7 +115,18 @@ export class WorkPackageSplitViewComponent extends WorkPackageSingleViewBase imp ); } }); - this.recentItemsService.add(wpId); + } + + protected override init():void { + super.init(); + const numericId = this.workPackage.id!; + this.wpTableFocus.updateFocus(numericId, false); + + if (this.wpTableSelection.isEmpty) { + this.wpTableSelection.setRowState(numericId, true); + } + + this.recentItemsService.add(numericId); } get activeTabComponent():Type|undefined { From 1924a8ccc0a462cdb393a5870db9f25aa728ace6 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Mon, 13 Apr 2026 15:57:35 +0300 Subject: [PATCH 4/7] Fix card view highlight by comparing route param against both id and displayId The route param may be a semantic identifier ("PROJ-7") which won't match workPackage.id (numeric "42"). Comparing against displayId as well handles both classic and semantic modes. --- .../wp-card-view/wp-single-card/wp-single-card.component.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts b/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts index 70ea0c0272fd..a03fa221ee42 100644 --- a/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts @@ -142,7 +142,8 @@ export class WorkPackageSingleCardComponent extends UntilDestroyedMixin implemen this.untilDestroyed(), map(() => { if (this.selectedWhenOpen) { - return this.uiRouterGlobals.params.workPackageId === this.workPackage.id; + const routeWpId = this.uiRouterGlobals.params.workPackageId; + return routeWpId === this.workPackage.id || routeWpId === this.workPackage.displayId; } return this.wpTableSelection.isSelected(this.workPackage.id!); From 118a5c8b33ee9fdfbf2320dc59f0f3dbb206b496 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Mon, 13 Apr 2026 15:57:40 +0300 Subject: [PATCH 5/7] Fix bulk delete split view close by resolving semantic route param Resolve $state.params.workPackageId to numeric PK via the States cache before comparing against the deleted IDs array. --- .../work-packages/services/work-package.service.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/features/work-packages/services/work-package.service.ts b/frontend/src/app/features/work-packages/services/work-package.service.ts index 919ba47ef9ae..5010a0448435 100644 --- a/frontend/src/app/features/work-packages/services/work-package.service.ts +++ b/frontend/src/app/features/work-packages/services/work-package.service.ts @@ -34,6 +34,7 @@ import { UrlParamsHelperService } from 'core-app/features/work-packages/componen import { ToastService } from 'core-app/shared/components/toaster/toast.service'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { HalDeletedEvent, HalEventsService } from 'core-app/features/hal/services/hal-events.service'; +import { States } from 'core-app/core/states/states.service'; @Injectable() export class WorkPackageService { @@ -47,7 +48,8 @@ export class WorkPackageService { private readonly UrlParamsHelper:UrlParamsHelperService, private readonly toastService:ToastService, private readonly I18n:I18nService, - private readonly halEvents:HalEventsService) { + private readonly halEvents:HalEventsService, + private readonly states:States) { } public performBulkDelete(ids:string[], defaultHandling:boolean) { @@ -68,8 +70,11 @@ export class WorkPackageService { ids.forEach((id) => this.halEvents.push({ _type: 'WorkPackage', id }, { eventType: 'deleted' } as HalDeletedEvent)); + const routeWpId = this.$state.params.workPackageId as string; + const wp = this.states.workPackages.get(routeWpId)?.value; + const numericId = wp?.id ?? routeWpId; if (this.$state.includes('**.list.details.**') - && ids.includes(this.$state.params.workPackageId)) { + && ids.includes(numericId)) { this.$state.go('work-packages.partitioned.list', this.$state.params); } }) From f38899eab7a4b2a59a71571710f61ba0f06375a9 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Mon, 13 Apr 2026 15:57:45 +0300 Subject: [PATCH 6/7] Fix WP cache dual-entry when route param is semantic ID Normalize this.workPackageId from semantic (e.g. "PROJ-7") to numeric PK after first WP load, ensuring downstream cache lookups use the canonical key. --- .../routing/wp-view-base/work-package-single-view.base.ts | 7 +++++++ 1 file changed, 7 insertions(+) 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..ff01f9894df2 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 @@ -150,6 +150,13 @@ export abstract class WorkPackageSingleViewBase extends UntilDestroyedMixin { .requireAndStream() .pipe(this.untilDestroyed()) .subscribe((wp:WorkPackageResource) => { + // Normalize semantic route param (e.g. "PROJ-7") to numeric PK + // for cache coherence — downstream code uses this.workPackageId + // as a cache key, and the canonical key is always numeric. + if (this.workPackageId !== wp.id && wp.id) { + this.workPackageId = wp.id; + } + if (!this.workPackage) { this.workPackage = wp; this.init(); From 1388c3ca5df9a99419f3f5022d6414ba657c6bad Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Mon, 13 Apr 2026 17:03:23 +0300 Subject: [PATCH 7/7] Add JSDoc to critical semantic ID functions Document the dual-purpose contract in observeWorkPackage (cache normalization), deferred focus in split view init, card highlight comparison logic, and best-effort fallback in resolveRoutingId. --- .../wp-single-card/wp-single-card.component.ts | 2 ++ .../features/work-packages/helpers/resolve-routing-id.ts | 4 +++- .../routing/wp-split-view/wp-split-view.component.ts | 8 ++++++++ .../routing/wp-view-base/work-package-single-view.base.ts | 6 ++++++ 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts b/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts index a03fa221ee42..e2133009e7b9 100644 --- a/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts @@ -142,6 +142,8 @@ export class WorkPackageSingleCardComponent extends UntilDestroyedMixin implemen this.untilDestroyed(), map(() => { if (this.selectedWhenOpen) { + // Route param may be semantic ("PROJ-7") or numeric ("42"). + // Compare against both id and displayId to handle both modes. const routeWpId = this.uiRouterGlobals.params.workPackageId; return routeWpId === this.workPackage.id || routeWpId === this.workPackage.displayId; } diff --git a/frontend/src/app/features/work-packages/helpers/resolve-routing-id.ts b/frontend/src/app/features/work-packages/helpers/resolve-routing-id.ts index 2b054df138c4..b617ea088f36 100644 --- a/frontend/src/app/features/work-packages/helpers/resolve-routing-id.ts +++ b/frontend/src/app/features/work-packages/helpers/resolve-routing-id.ts @@ -2,7 +2,9 @@ import { States } from 'core-app/core/states/states.service'; /** * Resolve a numeric work package ID to its semantic routing ID (e.g. "PROJ-42"). - * Falls back to the input ID if the WP is not in cache or has no displayId. + * Falls back to the input ID if the WP is not in cache or has no displayId — + * this is a best-effort lookup, not a guarantee. The URL just shows the + * numeric ID temporarily until the WP is cached. * * Used in navigation handlers where only the numeric PK is available from * data-work-package-id attributes, but the URL should show the semantic form. diff --git a/frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.component.ts b/frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.component.ts index 1ba966071f10..7ce6e4509078 100644 --- a/frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.component.ts +++ b/frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.component.ts @@ -117,6 +117,14 @@ export class WorkPackageSplitViewComponent extends WorkPackageSingleViewBase imp }); } + /** + * Set focus, selection, and recent-items after the WP has loaded. + * + * Intentionally deferred from ngOnInit because the route param + * (this.workPackageId) may be a semantic identifier like "PROJ-7", + * but focus/selection services are keyed by numeric PK. By the time + * init() runs, this.workPackage.id is guaranteed to be the numeric PK. + */ protected override init():void { super.init(); const numericId = this.workPackage.id!; 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 ff01f9894df2..cd499bb647fb 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 @@ -141,6 +141,12 @@ export abstract class WorkPackageSingleViewBase extends UntilDestroyedMixin { /** * Observe changes of work package and re-run initialization. * Needs to be run explicitly by descendants. + * + * Note: this.workPackageId may be a semantic identifier (e.g. "PROJ-7") + * from the route param. The API resolves it correctly, but the cache key + * would be "PROJ-7" while list queries cache the same WP under "42". + * After the first load we normalize to the numeric PK to prevent + * dual cache entries. */ protected observeWorkPackage():void { this