diff --git a/frontend/src/app/shared/components/fields/display/field-types/work-package-display-field.module.spec.ts b/frontend/src/app/shared/components/fields/display/field-types/work-package-display-field.module.spec.ts new file mode 100644 index 00000000000..f05e7bc1a13 --- /dev/null +++ b/frontend/src/app/shared/components/fields/display/field-types/work-package-display-field.module.spec.ts @@ -0,0 +1,109 @@ +import { WorkPackageDisplayField } from './work-package-display-field.module'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { DisplayFieldContext } from 'core-app/shared/components/fields/display/display-field.service'; +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { IFieldSchema } from 'core-app/shared/components/fields/field.base'; +import { Injector } from '@angular/core'; + +describe('WorkPackageDisplayField', () => { + let field:WorkPackageDisplayField; + + const mockI18n = { t: (key:string) => key }; + + const serviceMap = new Map([ + [I18nService, mockI18n], + ]); + + function buildField(parentAttrs:Record | null) { + const resource = { + parent: parentAttrs, + } as unknown as HalResource; + + const mockInjector = { + get: (token:unknown, notFoundValue?:unknown) => serviceMap.get(token) ?? notFoundValue ?? {}, + } as Injector; + + field = new WorkPackageDisplayField('parent', { + injector: mockInjector, + container: null, + options: {}, + } as unknown as DisplayFieldContext); + + field.apply(resource, { type: 'WorkPackage' } as IFieldSchema); + } + + describe('wpFormattedId', () => { + it('returns the semantic ID from a fully loaded linked WP', () => { + buildField({ + $loaded: true, + id: '123', + formattedId: 'PROJ-42', + displayId: 'PROJ-42', + href: '/api/v3/work_packages/123', + }); + + expect(field.wpFormattedId).toEqual('PROJ-42'); + }); + + it('returns the semantic ID from an unloaded linked WP when displayId is on the link', () => { + buildField({ + $loaded: false, + formattedId: 'PROJ-42', + displayId: 'PROJ-42', + href: '/api/v3/work_packages/123', + }); + + expect(field.wpFormattedId).toEqual('PROJ-42'); + }); + + it('falls back to prefixed numeric ID from an unloaded linked WP without displayId', () => { + buildField({ + $loaded: false, + formattedId: '#123', + displayId: '123', + href: '/api/v3/work_packages/123', + }); + + expect(field.wpFormattedId).toEqual('#123'); + }); + + it('returns empty string when the linked WP is absent', () => { + buildField(null); + + expect(field.wpFormattedId).toEqual(''); + }); + }); + + describe('wpRoutingId', () => { + it('returns the semantic displayId from a fully loaded linked WP', () => { + buildField({ + $loaded: true, + id: '123', + displayId: 'PROJ-42', + href: '/api/v3/work_packages/123', + }); + + expect(field.wpRoutingId).toEqual('PROJ-42'); + }); + + it('returns the semantic displayId from an unloaded linked WP when displayId is on the link', () => { + buildField({ + $loaded: false, + displayId: 'PROJ-42', + href: '/api/v3/work_packages/123', + }); + + expect(field.wpRoutingId).toEqual('PROJ-42'); + }); + + it('falls back to numeric displayId from an unloaded linked WP without semantic displayId', () => { + buildField({ + $loaded: false, + displayId: '123', + href: '/api/v3/work_packages/123', + }); + + expect(field.wpRoutingId).toEqual('123'); + }); + }); +}); diff --git a/frontend/src/app/shared/components/fields/display/field-types/work-package-display-field.module.ts b/frontend/src/app/shared/components/fields/display/field-types/work-package-display-field.module.ts index dee42c2fa50..45e754d87d3 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/work-package-display-field.module.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/work-package-display-field.module.ts @@ -55,38 +55,35 @@ export class WorkPackageDisplayField extends DisplayField { return this.value.id; } - // Read WP ID from href return this.value.href.match(/(\d+)$/)[0]; } /** - * Returns the identifier for URL routing when the linked WP is loaded, - * falling back to the numeric ID extracted from the href. - * - * Unlike `WorkPackageBaseResource.displayId`, this handles the case - * where the related resource is only a HAL link (not yet fetched). + * Returns the identifier for URL routing. + * Reads `displayId` from the linked resource whether or not it is fully + * loaded — the API now includes `displayId` on HAL link objects (e.g. + * the parent link), so `WorkPackageResource#displayId` resolves + * correctly from `$source._links.self.displayId` even for stubs. */ public get wpRoutingId():string { const linkedWp = this.value as WorkPackageResource | undefined; - if (linkedWp?.$loaded) { + if (linkedWp) { return linkedWp.displayId; } - return this.wpId as string; + return (this.wpId as string | null) ?? ''; } /** * Returns the work package ID formatted for display. * Classic mode: `#123` (hash-prefixed), Semantic mode: `PROJ-42` (no prefix). * - * Delegates to `WorkPackageResource#formattedId` when the linked resource - * is loaded. When unloaded, falls back to the numeric ID extracted from - * the self-link href — an unloaded HAL link carries only the href, not - * the resource's properties (the API always populates `displayId`, but - * we can't reach it until the link is fetched). + * Delegates to `WorkPackageResource#formattedId` for both loaded and + * unloaded stubs. The API includes `displayId` on HAL link objects so + * `formattedId` resolves the semantic identifier without a fetch. */ public get wpFormattedId():string { const linkedWp = this.value as WorkPackageResource | undefined; - if (linkedWp?.$loaded) { + if (linkedWp) { return linkedWp.formattedId; } diff --git a/lib/api/v3/work_packages/work_package_representer.rb b/lib/api/v3/work_packages/work_package_representer.rb index 5456ed0d860..fbdc479245a 100644 --- a/lib/api/v3/work_packages/work_package_representer.rb +++ b/lib/api/v3/work_packages/work_package_representer.rb @@ -582,7 +582,8 @@ module API if represented.parent&.visible? { href: api_v3_paths.work_package(represented.parent.id), - title: represented.parent.subject + title: represented.parent.subject, + displayId: represented.parent.display_id.to_s } else { diff --git a/spec/lib/api/v3/work_packages/work_package_at_timestamp_representer_rendering_spec.rb b/spec/lib/api/v3/work_packages/work_package_at_timestamp_representer_rendering_spec.rb index 8c8ea6f783d..ff6104ffe48 100644 --- a/spec/lib/api/v3/work_packages/work_package_at_timestamp_representer_rendering_spec.rb +++ b/spec/lib/api/v3/work_packages/work_package_at_timestamp_representer_rendering_spec.rb @@ -170,6 +170,7 @@ RSpec.describe API::V3::WorkPackages::WorkPackageAtTimestampRepresenter, "render "title" => version.name }, "parent" => { + "displayId" => parent.display_id.to_s, "href" => api_v3_paths.work_package(parent.id), "title" => parent.subject }, @@ -431,6 +432,7 @@ RSpec.describe API::V3::WorkPackages::WorkPackageAtTimestampRepresenter, "render "title" => version.name }, "parent" => { + "displayId" => parent.display_id.to_s, "href" => api_v3_paths.work_package(parent.id), "title" => parent.subject }, 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 e9987ab8d5b..78c2251de5e 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 @@ -1089,6 +1089,11 @@ RSpec.describe API::V3::WorkPackages::WorkPackageRepresenter do let(:href) { api_v3_paths.work_package(visible_parent.id) } let(:title) { visible_parent.subject } end + + it "exposes displayId on the parent link" do + expect(parse_json(subject).dig("_links", "parent", "displayId")) + .to eq(visible_parent.display_id.to_s) + end end context "when parent not visible" do