Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/models/work_package/semantic_identifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
akabiru marked this conversation as resolved.
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
24 changes: 21 additions & 3 deletions frontend/src/app/features/hal/resources/work-package-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer a more clean way of detecting the semantic mode in the frontend but since we don't have that, the regex is probably fine. However, please add that as a const to the work-package-id-pattern.ts file that you created. Thus it is at least reusable

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reckon one way could to check settings on the frontend via the configurations module- but that felt like overkill- and I suppose a small heuristic via regex is sufficient as the case is not too complex? That said, I agree on extracting the function for re-usability 👍🏾

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

}

public updatedAt:Date;

public lockVersion:number;
Expand Down Expand Up @@ -170,18 +188,18 @@ export class WorkPackageBaseResource extends HalResource {
}

/**
* Return "<type name>: <subject> (#<id>)" if type and id are known.
* Return "<type name>: <subject> (<formattedId>)" if type and id are known.
*/
public subjectWithType(truncateSubject = 40):string {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
return `${this.type.name}: ${this.subjectWithId(truncateSubject)}`;
}

/**
* Return "<subject> (#<id>)" if the id is known.
* Return "<subject> (<formattedId>)" 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}`;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/app/shared/helpers/work-package-id-pattern.ts
Original file line number Diff line number Diff line change
@@ -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+';
2 changes: 1 addition & 1 deletion lib/api/v3/work_packages/work_package_sql_representer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions spec/models/work_package/semantic_identifier_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading