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