diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml
index 5d500c186c3d..de421414ab1e 100644
--- a/config/locales/js-en.yml
+++ b/config/locales/js-en.yml
@@ -79,6 +79,7 @@ en:
single: 'Select "%{name}"'
remove: "Remove %{name}"
autocompleter:
+ new_label: "(New label)"
notFoundText: "No items found"
placeholder: "Type to search"
project:
diff --git a/frontend/src/app/core/apiv3/api-v3.service.ts b/frontend/src/app/core/apiv3/api-v3.service.ts
index 965968a0e959..25a5dd019548 100644
--- a/frontend/src/app/core/apiv3/api-v3.service.ts
+++ b/frontend/src/app/core/apiv3/api-v3.service.ts
@@ -175,6 +175,9 @@ export class ApiV3Service {
// /api/v3/job_statuses
public readonly job_statuses = this.apiV3CollectionEndpoint('job_statuses');
+ // /api/v3/labels
+ public readonly labels = this.apiV3CollectionEndpoint('labels');
+
// /api/v3/views
public readonly views = this.apiV3CustomEndpoint(ApiV3ViewsPaths);
diff --git a/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component.html b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component.html
new file mode 100644
index 000000000000..a9e332af2b19
--- /dev/null
+++ b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component.html
@@ -0,0 +1,10 @@
+
+ {{ item.name }}
+
+
+
+ {{ search }} {{ newLabelSuffix }}
+
diff --git a/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component.ts b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component.ts
new file mode 100644
index 000000000000..27d517797352
--- /dev/null
+++ b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component.ts
@@ -0,0 +1,46 @@
+//-- copyright
+// OpenProject is an open source project management software.
+// Copyright (C) the OpenProject GmbH
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License version 3.
+//
+// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+// Copyright (C) 2006-2013 Jean-Philippe Lang
+// Copyright (C) 2010-2013 the ChiliProject Team
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+//
+// See COPYRIGHT and LICENSE files for more details.
+//++
+
+import { ChangeDetectionStrategy, Component, TemplateRef, ViewChild, inject } from '@angular/core';
+import { I18nService } from 'core-app/core/i18n/i18n.service';
+import {
+ IAutocompleterTemplateComponent,
+} from 'core-app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component';
+
+@Component({
+ templateUrl: './labels-autocompleter-template.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ standalone: false,
+})
+export class LabelsAutocompleterTemplateComponent implements IAutocompleterTemplateComponent {
+ @ViewChild('optionTemplate') optionTemplate?:TemplateRef;
+
+ @ViewChild('tagTemplate') tagTemplate?:TemplateRef;
+
+ readonly newLabelSuffix = inject(I18nService).t('js.autocompleter.new_label');
+}
diff --git a/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component.spec.ts b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component.spec.ts
new file mode 100644
index 000000000000..6a00a7e385fe
--- /dev/null
+++ b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component.spec.ts
@@ -0,0 +1,152 @@
+//-- copyright
+// OpenProject is an open source project management software.
+// Copyright (C) the OpenProject GmbH
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License version 3.
+//
+// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+// Copyright (C) 2006-2013 Jean-Philippe Lang
+// Copyright (C) 2010-2013 the ChiliProject Team
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+//
+// See COPYRIGHT and LICENSE files for more details.
+//++
+
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { provideHttpClient, withInterceptorsFromDi, withXhr } from '@angular/common/http';
+import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
+import { NgSelectModule } from '@ng-select/ng-select';
+import { of } from 'rxjs';
+import { States } from 'core-app/core/states/states.service';
+import { I18nService } from 'core-app/core/i18n/i18n.service';
+import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service';
+import { CurrentUserService } from 'core-app/core/current-user/current-user.service';
+import { LabelsAutocompleterComponent } from './labels-autocompleter.component';
+
+describe('LabelsAutocompleterComponent', () => {
+ let component:LabelsAutocompleterComponent;
+ let httpMock:HttpTestingController;
+ let halNotification:{ handleRawError:ReturnType };
+ let currentUser:{ hasCapabilities$:ReturnType };
+
+ const i18nStub = { t: (key:string) => key };
+
+ beforeEach(async () => {
+ halNotification = { handleRawError: vi.fn() };
+ currentUser = { hasCapabilities$: vi.fn(() => of(true)) };
+
+ await TestBed.configureTestingModule({
+ declarations: [LabelsAutocompleterComponent],
+ imports: [NgSelectModule],
+ schemas: [NO_ERRORS_SCHEMA],
+ providers: [
+ States,
+ provideHttpClient(withXhr(), withInterceptorsFromDi()),
+ provideHttpClientTesting(),
+ { provide: I18nService, useValue: i18nStub },
+ { provide: HalResourceNotificationService, useValue: halNotification },
+ { provide: CurrentUserService, useValue: currentUser },
+ ],
+ }).compileComponents();
+
+ component = TestBed.createComponent(LabelsAutocompleterComponent).componentInstance;
+ httpMock = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpMock.verify();
+ });
+
+ function pendingRequest() {
+ return httpMock.expectOne((req) => req.method === 'POST' && req.url.endsWith('/labels'));
+ }
+
+ it('does not offer to create a blank term', () => {
+ expect(component.createLabel(' ')).toBeUndefined();
+ });
+
+ it('resolves with the created label on success', async () => {
+ const promise = component.createLabel('Urgent');
+
+ pendingRequest().flush({ id: 5, name: 'Urgent', _links: { self: { href: '/api/v3/labels/5' } } });
+
+ await expect(promise).resolves.toEqual({ id: 5, name: 'Urgent', href: '/api/v3/labels/5' });
+ });
+
+ it('delegates errors to the generic handler and rejects', async () => {
+ const promise = component.createLabel('Urgent');
+
+ pendingRequest().flush({ _type: 'Error' }, { status: 422, statusText: 'Unprocessable Entity' });
+
+ await expect(promise).rejects.toBeTruthy();
+ expect(halNotification.handleRawError).toHaveBeenCalled();
+ });
+
+ it('ignores a second call while a request is in flight, then allows a retry', async () => {
+ const first = component.createLabel('Urgent');
+ const second = component.createLabel('Urgent');
+
+ expect(second).toBeUndefined();
+
+ pendingRequest().flush({ id: 5, name: 'Urgent', _links: { self: { href: '/api/v3/labels/5' } } });
+ await first;
+
+ const retry = component.createLabel('Urgent');
+ expect(retry).toBeDefined();
+ pendingRequest().flush({ id: 5, name: 'Urgent', _links: { self: { href: '/api/v3/labels/5' } } });
+ await retry;
+ });
+
+ it('resets the in-flight flag after an error so a retry can be sent', async () => {
+ const first = component.createLabel('Urgent');
+ pendingRequest().flush({ _type: 'Error' }, { status: 500, statusText: 'Internal Server Error' });
+ await expect(first).rejects.toBeTruthy();
+
+ const retry = component.createLabel('Urgent');
+ expect(retry).toBeDefined();
+ pendingRequest().flush({ id: 5, name: 'Urgent', _links: { self: { href: '/api/v3/labels/5' } } });
+ await retry;
+ });
+
+ it('enables addTag once the project grants work_packages/update, without throwing change-detection errors', () => {
+ const fixture = TestBed.createComponent(LabelsAutocompleterComponent);
+ fixture.componentRef.setInput('projectId', '1');
+
+ expect(() => fixture.detectChanges()).not.toThrow();
+ expect(typeof fixture.componentInstance.addTag).toBe('function');
+ expect(currentUser.hasCapabilities$).toHaveBeenCalledWith('work_packages/update', '1');
+ });
+
+ it('does not offer addTag when the project withholds work_packages/update', () => {
+ currentUser.hasCapabilities$.mockReturnValue(of(false));
+
+ const fixture = TestBed.createComponent(LabelsAutocompleterComponent);
+ fixture.componentRef.setInput('projectId', '1');
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.addTag).toBe(false);
+ });
+
+ it('does not offer addTag or query capabilities without a project', () => {
+ const fixture = TestBed.createComponent(LabelsAutocompleterComponent);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.addTag).toBe(false);
+ expect(currentUser.hasCapabilities$).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component.ts b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component.ts
new file mode 100644
index 000000000000..282ce159e2d0
--- /dev/null
+++ b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component.ts
@@ -0,0 +1,186 @@
+//-- copyright
+// OpenProject is an open source project management software.
+// Copyright (C) the OpenProject GmbH
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License version 3.
+//
+// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+// Copyright (C) 2006-2013 Jean-Philippe Lang
+// Copyright (C) 2010-2013 the ChiliProject Team
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+//
+// See COPYRIGHT and LICENSE files for more details.
+//++
+
+import { ChangeDetectionStrategy, Component, Input, OnInit, inject } from '@angular/core';
+import { keyBy } from 'lodash-es';
+import { BehaviorSubject, firstValueFrom, Observable, of, throwError } from 'rxjs';
+import { catchError, finalize, map, switchMap } from 'rxjs/operators';
+import {
+ OpAutocompleterComponent,
+} from 'core-app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component';
+import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder';
+import { addFiltersToPath } from 'core-app/core/apiv3/helpers/add-filters-to-path';
+import { IHALCollection } from 'core-app/core/apiv3/types/hal-collection.type';
+import { compareByAttribute } from 'core-app/shared/helpers/angular/tracking-functions';
+import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service';
+import { CurrentUserService } from 'core-app/core/current-user/current-user.service';
+import {
+ LabelsAutocompleterTemplateComponent,
+} from 'core-app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component';
+
+export const labelsAutocompleterSelector = 'op-labels-autocompleter';
+
+export interface ILabelAutocompleteItem {
+ id:string|number;
+ name:string;
+ href:string|null;
+}
+
+export interface IApiLabel {
+ id:string|number;
+ name:string;
+ _links:{ self:{ href:string|null } };
+}
+
+@Component({
+ templateUrl: '../op-autocompleter/op-autocompleter.component.html',
+ selector: labelsAutocompleterSelector,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ standalone: false,
+})
+export class LabelsAutocompleterComponent extends OpAutocompleterComponent implements OnInit {
+ getOptionsFn = this.getLabels.bind(this);
+
+ classes = 'op-labels-autocompleter--panel';
+
+ // Virtual scroll absolutely positions and translates the options container,
+ // which breaks the sticky create row's positioning against the scroll port.
+ virtualScroll = false;
+
+ readonly halNotification = inject(HalResourceNotificationService);
+
+ readonly currentUserService = inject(CurrentUserService);
+
+ private creatingLabel = false;
+
+ private readonly projectId$ = new BehaviorSubject(undefined);
+
+ @Input()
+ public set projectId(value:string|undefined) {
+ this.projectId$.next(value);
+ }
+
+ public get projectId():string|undefined {
+ return this.projectId$.value;
+ }
+
+ ngOnInit():void {
+ super.ngOnInit();
+ this.applyTemplates(LabelsAutocompleterTemplateComponent);
+
+ this
+ .projectId$
+ .pipe(
+ switchMap((projectId) => (projectId ? this.currentUserService.hasCapabilities$('work_packages/update', projectId) : of(false))),
+ this.untilDestroyed(),
+ )
+ .subscribe((canCreateLabels) => {
+ this.addTag = canCreateLabels ? this.createLabel.bind(this) : false;
+ });
+ }
+
+ public keydowned(val:unknown):void {
+ super.keydowned(val);
+
+ const key = (val as KeyboardEvent)?.key;
+ if (key !== 'ArrowDown' && key !== 'ArrowUp') {
+ return;
+ }
+
+ // ng-select scrolls the marked option flush with the scroll port's bottom edge, which the sticky create row also occupies.
+ requestAnimationFrame(() => this.keepMarkedOptionAboveCreateRow());
+ }
+
+ private keepMarkedOptionAboveCreateRow():void {
+ const scrollHost = document.getElementById(this.ngSelectInstance?.dropdownId ?? '');
+ const marked = scrollHost?.querySelector('.ng-option-marked');
+ const createRow = scrollHost
+ ?.querySelector('.labels-autocompleter--create-option')
+ ?.closest('.ng-option');
+
+ if (!scrollHost || !marked || !createRow || marked === createRow) {
+ return;
+ }
+
+ const overlap = marked.getBoundingClientRect().bottom - createRow.getBoundingClientRect().top;
+ if (overlap > 0) {
+ scrollHost.scrollTop += overlap;
+ }
+ }
+
+ public getLabels(searchTerm?:string):Observable {
+ const filterObject = keyBy(this.filters, 'name');
+ const filters = ApiV3FilterBuilder.fromFilterObject(filterObject);
+ if (searchTerm?.length) {
+ filters.add('name', '~', [searchTerm]);
+ }
+
+ const filteredURL = addFiltersToPath(this.url, filters);
+ filteredURL.searchParams.set('pageSize', '-1');
+ filteredURL.searchParams.set('select', 'elements/id,elements/name,elements/self,total,count,pageSize');
+
+ return this
+ .http
+ .get>(filteredURL.toString())
+ .pipe(
+ map((res) => res._embedded.elements.map((label) => ({ id: label.id, name: label.name, href: label._links.self.href }))),
+ );
+ }
+
+ public createLabel(searchTerm:string):Promise|undefined {
+ const name = searchTerm.trim();
+ if (!name || this.creatingLabel) {
+ return undefined;
+ }
+
+ this.creatingLabel = true;
+
+ return firstValueFrom(
+ this
+ .http
+ .post(this.apiV3Service.labels.toString(), { name })
+ .pipe(
+ map((label) => ({ id: label.id, name: label.name, href: label._links.self.href })),
+ catchError((error:unknown) => {
+ this.halNotification.handleRawError(error);
+
+ return throwError(() => error);
+ }),
+ finalize(() => { this.creatingLabel = false; }),
+ ),
+ );
+ }
+
+ protected defaultCompareWithFunction():(a:unknown, b:unknown) => boolean {
+ return compareByAttribute('href', 'name');
+ }
+
+ protected defaultTrackByFunction():(item:{ href:unknown, name:unknown }) => unknown {
+ return (item) => item.href ?? item.name;
+ }
+}
diff --git a/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.html b/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.html
index d498eabd5b61..7538f689fe44 100644
--- a/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.html
+++ b/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.html
@@ -134,6 +134,17 @@
}
+ @if (tagTemplate) {
+
+
+
+ }
+
@if (notFoundTemplate) {
;
footerTemplate?:TemplateRef;
notFoundTemplate?:TemplateRef;
+ tagTemplate?:TemplateRef;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-redundant-type-constituents
@@ -317,6 +318,8 @@ export class OpAutocompleterComponent;
+ tagTemplate:TemplateRef;
+
readonly opAutocompleterService = inject(OpAutocompleterService);
ngOnInit() {
@@ -554,7 +557,7 @@ export class OpAutocompleterComponent {
+ ['optionTemplate', 'headerTemplate', 'labelTemplate', 'footerTemplate', 'notFoundTemplate', 'tagTemplate'].forEach((name:keyof IAutocompleterTemplateComponent) => {
const template = componentRef.instance[name];
if (template) {
this[name] = template;
diff --git a/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts b/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts
index cac7d5f3e9fa..7ff14f10d9c6 100644
--- a/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts
+++ b/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts
@@ -48,6 +48,12 @@ import {
import {
UserAutocompleterComponent,
} from 'core-app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component';
+import {
+ LabelsAutocompleterComponent,
+} from 'core-app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component';
+import {
+ LabelsAutocompleterTemplateComponent,
+} from 'core-app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component';
import {
MeetingAutocompleterComponent,
} from 'core-app/shared/components/autocompleter/meeting-autocompleter/meeting-autocompleter.component';
@@ -109,6 +115,8 @@ export const OPENPROJECT_AUTOCOMPLETE_COMPONENTS = [
DraggableAutocompleteComponent,
UserAutocompleterComponent,
UserAutocompleterTemplateComponent,
+ LabelsAutocompleterComponent,
+ LabelsAutocompleterTemplateComponent,
ResourceAllocationAutocompleterComponent,
ResourceAllocationAutocompleterTemplateComponent,
MeetingAutocompleterTemplateComponent,
diff --git a/frontend/src/app/shared/components/fields/display/display-field.initializer.ts b/frontend/src/app/shared/components/fields/display/display-field.initializer.ts
index fe2831455430..76abdfb13ebd 100644
--- a/frontend/src/app/shared/components/fields/display/display-field.initializer.ts
+++ b/frontend/src/app/shared/components/fields/display/display-field.initializer.ts
@@ -114,7 +114,7 @@ export function initializeCoreDisplayFields(displayFieldService:DisplayFieldServ
'Workspace'])
.addFieldType(ProjectPhaseDisplayField, 'projectPhase', ['ProjectPhase'])
.addFieldType(ResourcesDisplayField, 'resources', ['[]CustomOption', '[]CustomField::Hierarchy::Item'])
- .addFieldType(ResourcesDisplayField, 'resources', ['[]Version'])
+ .addFieldType(ResourcesDisplayField, 'resources', ['[]Version', '[]Label'])
.addFieldType(MultipleUserFieldModule, 'users', ['[]User'])
.addFieldType(FormattableDisplayField, 'formattable', ['Formattable'])
.addFieldType(DaysDurationDisplayField, 'duration', ['duration'])
diff --git a/frontend/src/app/shared/components/fields/display/field-types/resources-display-field.module.spec.ts b/frontend/src/app/shared/components/fields/display/field-types/resources-display-field.module.spec.ts
new file mode 100644
index 000000000000..f0600e88c89f
--- /dev/null
+++ b/frontend/src/app/shared/components/fields/display/field-types/resources-display-field.module.spec.ts
@@ -0,0 +1,79 @@
+//-- copyright
+// OpenProject is an open source project management software.
+// Copyright (C) the OpenProject GmbH
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License version 3.
+//
+// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+// Copyright (C) 2006-2013 Jean-Philippe Lang
+// Copyright (C) 2010-2013 the ChiliProject Team
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+//
+// See COPYRIGHT and LICENSE files for more details.
+//++
+
+import { ResourcesDisplayField } from './resources-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';
+
+describe('ResourcesDisplayField', () => {
+ const mockI18n = { t: (key:string) => key };
+
+ const serviceMap = new Map([
+ [I18nService, mockI18n],
+ ]);
+
+ const mockInjector = {
+ get: (token:unknown, notFoundValue?:unknown) => serviceMap.get(token) ?? notFoundValue ?? {},
+ };
+
+ function buildField(name:string, type:string, values:string[]):ResourcesDisplayField {
+ const resource = {
+ [name]: values.map((value) => ({ name: value })),
+ } as unknown as HalResource;
+
+ const field = new ResourcesDisplayField(name, {
+ injector: mockInjector,
+ container: 'single-view',
+ options: {},
+ } as unknown as DisplayFieldContext);
+
+ field.apply(resource, { type } as IFieldSchema);
+
+ return field;
+ }
+
+ it('joins the resource names with a comma for valueString', () => {
+ const field = buildField('labels', '[]Label', ['Bug', 'Feature', 'Needs review']);
+
+ expect(field.valueString).toEqual('Bug, Feature, Needs review');
+ });
+
+ it('returns an empty string for valueString when there are no values', () => {
+ const field = buildField('labels', '[]Label', []);
+
+ expect(field.valueString).toEqual('');
+ });
+
+ it('joins target version names with a comma for valueString', () => {
+ const field = buildField('targetVersions', '[]Version', ['Version 1', 'Version 2']);
+
+ expect(field.valueString).toEqual('Version 1, Version 2');
+ });
+});
diff --git a/frontend/src/app/shared/components/fields/display/field-types/resources-display-field.module.ts b/frontend/src/app/shared/components/fields/display/field-types/resources-display-field.module.ts
index cd9c76f0a915..ba7e7fee0273 100644
--- a/frontend/src/app/shared/components/fields/display/field-types/resources-display-field.module.ts
+++ b/frontend/src/app/shared/components/fields/display/field-types/resources-display-field.module.ts
@@ -50,6 +50,10 @@ export class ResourcesDisplayField extends DisplayField {
return [];
}
+ public get valueString():string {
+ return this.stringValue.join(', ');
+ }
+
public render(element:HTMLElement, displayText:string):void {
const values = this.stringValue;
element.innerHTML = '';
diff --git a/frontend/src/app/shared/components/fields/edit/edit-field.initializer.ts b/frontend/src/app/shared/components/fields/edit/edit-field.initializer.ts
index 01967f873396..bc09de16a5e7 100644
--- a/frontend/src/app/shared/components/fields/edit/edit-field.initializer.ts
+++ b/frontend/src/app/shared/components/fields/edit/edit-field.initializer.ts
@@ -84,6 +84,9 @@ import {
import {
VersionsEditFieldComponent,
} from 'core-app/shared/components/fields/edit/field-types/versions-edit-field.component';
+import {
+ LabelsEditFieldComponent,
+} from 'core-app/shared/components/fields/edit/field-types/labels-edit-field.component';
import { ProjectPhaseAutocompleterComponent } from '../../autocompleter/project-phase-autocompleter/project-phase-autocompleter.component';
export function initializeCoreEditFields(editFieldService:EditFieldService, selectAutocompleterRegisterService:SelectAutocompleterRegisterService) {
@@ -95,6 +98,7 @@ export function initializeCoreEditFields(editFieldService:EditFieldService, sele
.addFieldType(ProgressPopoverEditFieldComponent, 'progress', ['Progress'])
.addFieldType(ProjectEditFieldComponent, 'project', ['Project'])
.addFieldType(UserEditFieldComponent, 'user', ['User'])
+ .addFieldType(LabelsEditFieldComponent, 'labels', ['[]Label'])
.addFieldType(SelectEditFieldComponent, 'select', [
'Priority',
'ProjectPhase',
diff --git a/frontend/src/app/shared/components/fields/edit/field-types/labels-edit-field.component.html b/frontend/src/app/shared/components/fields/edit/field-types/labels-edit-field.component.html
new file mode 100644
index 000000000000..cd9ef7d53a36
--- /dev/null
+++ b/frontend/src/app/shared/components/fields/edit/field-types/labels-edit-field.component.html
@@ -0,0 +1,28 @@
+@if (url) {
+
+
+ @if (!handler.inEditMode) {
+
+ }
+}
diff --git a/frontend/src/app/shared/components/fields/edit/field-types/labels-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/labels-edit-field.component.ts
new file mode 100644
index 000000000000..bea58a215763
--- /dev/null
+++ b/frontend/src/app/shared/components/fields/edit/field-types/labels-edit-field.component.ts
@@ -0,0 +1,69 @@
+//-- copyright
+// OpenProject is an open source project management software.
+// Copyright (C) the OpenProject GmbH
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License version 3.
+//
+// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+// Copyright (C) 2006-2013 Jean-Philippe Lang
+// Copyright (C) 2010-2013 the ChiliProject Team
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+//
+// See COPYRIGHT and LICENSE files for more details.
+//++
+
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import {
+ EditFieldComponent,
+} from 'core-app/shared/components/fields/edit/edit-field.component';
+import { HalResourceService } from 'core-app/features/hal/services/hal-resource.service';
+import isNewResource from 'core-app/features/hal/helpers/is-new-resource';
+import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link';
+import {
+ ILabelAutocompleteItem,
+} from 'core-app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component';
+
+@Component({
+ templateUrl: './labels-edit-field.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ standalone: false,
+})
+export class LabelsEditFieldComponent extends EditFieldComponent {
+ readonly halResourceService = inject(HalResourceService);
+
+ isNew = isNewResource(this.resource as { id:string|null });
+
+ url:string;
+
+ public text = {
+ save: this.I18n.t('js.inplace.button_save', { attribute: this.schema.name }),
+ cancel: this.I18n.t('js.inplace.button_cancel', { attribute: this.schema.name }),
+ };
+
+ initialize():void {
+ const link = this.schema.allowedValues as CallableHalLink|undefined;
+ if (link) {
+ this.url = link.$link.href!;
+ }
+ }
+
+ public onModelChange(labels?:ILabelAutocompleteItem[]):void {
+ this.value = (labels ?? []).map(
+ ({ id, name, href }) => this.halResourceService.createHalResourceOfType('Label', { id, name, href }),
+ );
+ }
+}
diff --git a/frontend/src/app/shared/components/fields/openproject-fields.module.ts b/frontend/src/app/shared/components/fields/openproject-fields.module.ts
index e80823ffd576..f08445a387d0 100644
--- a/frontend/src/app/shared/components/fields/openproject-fields.module.ts
+++ b/frontend/src/app/shared/components/fields/openproject-fields.module.ts
@@ -65,6 +65,7 @@ import { ProgressPopoverEditFieldComponent } from 'core-app/shared/components/fi
import { OpExclusionInfoComponent } from 'core-app/shared/components/fields/display/info/op-exclusion-info.component';
import { UserEditFieldComponent } from './edit/field-types/user-edit-field.component';
import { VersionsEditFieldComponent } from 'core-app/shared/components/fields/edit/field-types/versions-edit-field.component';
+import { LabelsEditFieldComponent } from 'core-app/shared/components/fields/edit/field-types/labels-edit-field.component';
import { DaysDurationEditFieldComponent } from 'core-app/shared/components/fields/edit/field-types/days-duration-edit-field.component';
import { CombinedDateEditFieldComponent } from './edit/field-types/combined-date-edit-field.component';
import { NgSelectModule } from '@ng-select/ng-select';
@@ -120,6 +121,7 @@ import { FormsModule } from '@angular/forms';
ProjectEditFieldComponent,
UserEditFieldComponent,
VersionsEditFieldComponent,
+ LabelsEditFieldComponent,
WorkPackageEditFieldComponent,
EditFormComponent,
DisplayFieldComponent,
diff --git a/frontend/src/global_styles/content/_autocomplete.sass b/frontend/src/global_styles/content/_autocomplete.sass
index 89d57e3a095b..ba52ddd27836 100644
--- a/frontend/src/global_styles/content/_autocomplete.sass
+++ b/frontend/src/global_styles/content/_autocomplete.sass
@@ -196,6 +196,19 @@ div.autocomplete
.ng-dropdown-footer
padding: 0 !important
+// ng-select gives the addTag row no class of its own; :has() reaches it via
+// the marker element in the labels tag template.
+.op-labels-autocompleter--panel
+ .ng-option:has(> .labels-autocompleter--create-option)
+ position: sticky
+ bottom: 0
+ border-top: 1px solid var(--borderColor-default)
+
+ // The hover token is translucent; layering it over the body background keeps the pinned row opaque.
+ &.ng-option-marked
+ background-color: var(--body-background) !important
+ background-image: linear-gradient(var(--control-transparent-bgColor-hover), var(--control-transparent-bgColor-hover)) !important
+
.ng-placeholder
line-height: 2rem
diff --git a/lib/api/v3/work_packages/schema/typed_schema_contract.rb b/lib/api/v3/work_packages/schema/typed_schema_contract.rb
index 9713703d9588..e77b1a3fcd3a 100644
--- a/lib/api/v3/work_packages/schema/typed_schema_contract.rb
+++ b/lib/api/v3/work_packages/schema/typed_schema_contract.rb
@@ -37,6 +37,7 @@ module Schema
class TypedSchemaContract < ::WorkPackages::BaseContract
default_attribute_permission %i[edit_work_packages add_work_packages]
attribute_permission :project_id, :move_work_packages
+ attribute_permission :labels, :edit_work_packages
end
end
end
diff --git a/spec/features/work_packages/details/inplace_editor/labels_editor_spec.rb b/spec/features/work_packages/details/inplace_editor/labels_editor_spec.rb
new file mode 100644
index 000000000000..428d64cdd756
--- /dev/null
+++ b/spec/features/work_packages/details/inplace_editor/labels_editor_spec.rb
@@ -0,0 +1,175 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+require "features/work_packages/details/inplace_editor/shared_examples"
+require "features/work_packages/shared_contexts"
+require "support/edit_fields/edit_field"
+require "features/work_packages/work_packages_page"
+
+RSpec.describe "labels inplace editor", :js, with_flag: { work_package_labels: true } do
+ let(:project) { create(:project) }
+ let!(:label) { create(:label, name: "Bug") }
+ let!(:other_label) { create(:label, name: "Feature") }
+ let(:work_package) { create(:work_package, project:) }
+ let(:user) do
+ create(:user,
+ member_with_permissions: { project => %i[view_work_packages edit_work_packages] })
+ end
+
+ before do
+ login_as(user)
+ end
+
+ context "in the full view" do
+ let(:work_package_page) { Pages::FullWorkPackage.new(work_package) }
+ let(:field) { work_package_page.edit_field(:labels) }
+
+ before do
+ create(:labeling, label:, labelable: work_package)
+ work_package_page.visit!
+ work_package_page.ensure_page_loaded
+ end
+
+ it "allows picking a label and removing another in the same edit" do
+ field.expect_state_text(label.name)
+
+ field.activate!
+ field.set_value(other_label.name)
+ field.expect_selected_values(label.name, other_label.name)
+ field.unset_value(label.name, multi: true)
+ field.submit_by_dashboard
+
+ field.expect_state_text(other_label.name)
+ expect(field.field_container).to have_no_text(label.name)
+ expect(work_package.reload.labels).to contain_exactly(other_label)
+ end
+
+ it "creates a new label from the search term via the keyboard and adds it to the selection" do
+ new_label_name = "Urgent"
+ new_label_suffix = I18n.t("js.autocompleter.new_label")
+ create_option_text = "#{new_label_name} #{new_label_suffix}"
+
+ field.activate!
+
+ dropdown = field.autocomplete(new_label_name, select: false)
+ expect(dropdown).to have_selector(:list_box_option, text: create_option_text)
+
+ field.autocomplete_selector.send_keys(:return)
+
+ field.expect_selected_values(label.name, new_label_name)
+ field.submit_by_dashboard
+
+ field.expect_state_text(new_label_name)
+ expect(Label.named(new_label_name)).to be_present
+ expect(work_package.reload.labels.map(&:name)).to contain_exactly(label.name, new_label_name)
+ end
+
+ it "offers no create option for an existing name in a different case" do
+ new_label_suffix = I18n.t("js.autocompleter.new_label")
+ duplicate_option_text = "#{other_label.name.upcase} #{new_label_suffix}"
+
+ field.activate!
+
+ dropdown = field.autocomplete(other_label.name.upcase, select: false)
+ expect(dropdown).to have_selector(:list_box_option, text: other_label.name)
+ expect(dropdown).to have_no_selector(:list_box_option, text: duplicate_option_text)
+ end
+
+ it "discards the selection when the edit is cancelled" do
+ field.activate!
+ field.set_value(other_label.name)
+ field.expect_selected_values(label.name, other_label.name)
+
+ field.cancel_by_escape
+
+ field.expect_inactive!
+ field.expect_state_text(label.name)
+ expect(field.field_container).to have_no_text(other_label.name)
+ expect(work_package.reload.labels).to contain_exactly(label)
+ end
+
+ context "when the user can only view work packages" do
+ let(:user) do
+ create(:user, member_with_permissions: { project => %i[view_work_packages] })
+ end
+
+ it "shows the label names as read only" do
+ field.expect_state_text(label.name)
+ field.expect_read_only
+ end
+ end
+ end
+
+ context "in the split view" do
+ let(:work_package_page) { Pages::PrimerizedSplitWorkPackage.new(work_package, project) }
+ let(:field) { work_package_page.edit_field(:labels) }
+
+ before do
+ create(:labeling, label:, labelable: work_package)
+ work_package_page.visit!
+ work_package_page.ensure_page_loaded
+ end
+
+ it "renders and shows the current labels when activated" do
+ field.expect_state_text(label.name)
+
+ field.activate!
+ field.expect_selected_values(label.name)
+ end
+ end
+
+ context "in the create form" do
+ let!(:status) { create(:default_status) }
+ let!(:priority) { create(:default_priority) }
+ let(:work_package_page) { Pages::FullWorkPackageCreate.new(project:) }
+ let(:field) { work_package_page.edit_field(:labels) }
+
+ before { work_package_page.visit! }
+
+ context "without edit_work_packages" do
+ let(:user) do
+ create(:user, member_with_permissions: { project => %i[view_work_packages add_work_packages] })
+ end
+
+ it "allows picking existing labels but offers no option to create one" do
+ new_label_suffix = I18n.t("js.autocompleter.new_label")
+
+ field.set_value(label.name)
+ field.expect_selected_values(label.name)
+
+ dropdown = field.autocomplete(other_label.name.upcase, select: false)
+ expect(dropdown).to have_selector(:list_box_option, text: other_label.name)
+
+ dropdown = field.autocomplete("Unknown", select: false)
+ expect(dropdown).to have_no_selector(:list_box_option, text: "Unknown #{new_label_suffix}")
+ end
+ end
+
+ context "with edit_work_packages" do
+ let(:user) do
+ create(:user,
+ member_with_permissions: { project => %i[view_work_packages add_work_packages edit_work_packages] })
+ end
+
+ it "creates a new label from the search term and assigns it" do
+ new_label_name = "Urgent"
+ new_label_suffix = I18n.t("js.autocompleter.new_label")
+ create_option_text = "#{new_label_name} #{new_label_suffix}"
+
+ dropdown = field.autocomplete(new_label_name, select: false)
+ expect(dropdown).to have_selector(:list_box_option, text: create_option_text)
+
+ field.autocomplete_selector.send_keys(:return)
+
+ field.expect_selected_values(new_label_name)
+
+ work_package_page.set_attributes({ subject: "Work package with a new label" })
+ work_package_page.save!
+ work_package_page.expect_and_dismiss_toaster(message: "Successful creation.")
+
+ expect(Label.named(new_label_name)).to be_present
+ expect(WorkPackage.last.labels.map(&:name)).to contain_exactly(new_label_name)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/v3/work_packages/work_packages_schemas_resource_spec.rb b/spec/requests/api/v3/work_packages/work_packages_schemas_resource_spec.rb
index 2967badfaab3..030969da803d 100644
--- a/spec/requests/api/v3/work_packages/work_packages_schemas_resource_spec.rb
+++ b/spec/requests/api/v3/work_packages/work_packages_schemas_resource_spec.rb
@@ -162,6 +162,30 @@
end
end
+ context "when labels are enabled", with_flag: { work_package_labels: true } do
+ context "with add_work_packages only" do
+ let(:role) do
+ create(:project_role, permissions: %i[view_work_packages add_work_packages change_work_package_status])
+ end
+
+ it "marks labels as not writable" do
+ expect(last_response.body)
+ .to be_json_eql(false.to_json)
+ .at_path("labels/writable")
+ end
+ end
+
+ context "with edit_work_packages" do
+ let(:role) { create(:project_role, permissions: %i[view_work_packages edit_work_packages]) }
+
+ it "marks labels as writable" do
+ expect(last_response.body)
+ .to be_json_eql(true.to_json)
+ .at_path("labels/writable")
+ end
+ end
+ end
+
context "id is too long" do
it_behaves_like "not found" do
let(:schema_path) { "#{api_v3_paths.work_package_schema project.id, type.id}-1" }
diff --git a/spec/support/edit_fields/edit_field.rb b/spec/support/edit_fields/edit_field.rb
index bfe5c5575bc5..68230f028517 100644
--- a/spec/support/edit_fields/edit_field.rb
+++ b/spec/support/edit_fields/edit_field.rb
@@ -107,6 +107,11 @@ def expect_display_value(value)
.to have_content(value)
end
+ def expect_selected_values(*names)
+ expect(field_container).to have_css(".ng-value-label", count: names.size)
+ names.each { expect(field_container).to have_css(".ng-value-label", text: it) }
+ end
+
##
# Activate the field and check it opened correctly
# @return [EditField] self
@@ -327,6 +332,8 @@ def derive_field_type
"create-autocompleter"
when :targetVersions
"ng-select"
+ when :labels
+ "op-labels-autocompleter"
when :project
"op-project-autocompleter"
when :activity