From e2a1436d45dc4d215cd6652b6ff6e2936ff7446c Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 23 Sep 2026 22:39:10 +0300 Subject: [PATCH 01/12] Show and edit labels on the work package view Labels render like the other multi-value attributes and are edited in a multi-select that searches on the server so the workspace-relevance ordering is kept. A dedicated labels autocompleter holds the fetching so other surfaces can reuse it. --- .../labels-autocompleter.component.ts | 90 +++++++++++++++++++ .../openproject-autocompleter.module.ts | 4 + .../display/display-field.initializer.ts | 2 +- .../fields/edit/edit-field.initializer.ts | 4 + .../labels-edit-field.component.html | 29 ++++++ .../labels-edit-field.component.ts | 69 ++++++++++++++ .../fields/openproject-fields.module.ts | 2 + .../inplace_editor/labels_editor_spec.rb | 69 ++++++++++++++ spec/support/edit_fields/edit_field.rb | 10 +++ 9 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component.ts create mode 100644 frontend/src/app/shared/components/fields/edit/field-types/labels-edit-field.component.html create mode 100644 frontend/src/app/shared/components/fields/edit/field-types/labels-edit-field.component.ts create mode 100644 spec/features/work_packages/details/inplace_editor/labels_editor_spec.rb 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..d16cdcc1292a --- /dev/null +++ b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component.ts @@ -0,0 +1,90 @@ +//-- 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 } from '@angular/core'; +import { keyBy } from 'lodash-es'; +import { Observable } from 'rxjs'; +import { map } 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'; + +export const labelsAutocompleterSelector = 'op-labels-autocompleter'; + +export interface ILabelAutocompleteItem { + id:string|number; + name:string; + href:string|null; +} + +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 { + getOptionsFn = this.getLabels.bind(this); + + 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 }))), + ); + } + + 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/openproject-autocompleter.module.ts b/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts index cac7d5f3e9fa..6c9a1e8348d4 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,9 @@ 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 { MeetingAutocompleterComponent, } from 'core-app/shared/components/autocompleter/meeting-autocompleter/meeting-autocompleter.component'; @@ -109,6 +112,7 @@ export const OPENPROJECT_AUTOCOMPLETE_COMPONENTS = [ DraggableAutocompleteComponent, UserAutocompleterComponent, UserAutocompleterTemplateComponent, + LabelsAutocompleterComponent, 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/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..69d7a228f633 --- /dev/null +++ b/frontend/src/app/shared/components/fields/edit/field-types/labels-edit-field.component.html @@ -0,0 +1,29 @@ +@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/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..b1f149bab2cd --- /dev/null +++ b/spec/features/work_packages/details/inplace_editor/labels_editor_spec.rb @@ -0,0 +1,69 @@ +# 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 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 + create(:labeling, label:, labelable: work_package) + 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 + work_package_page.visit! + work_package_page.ensure_page_loaded + end + + it "allows picking a label and removing another in the same edit, saving in one request" 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 + 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 + work_package_page.visit! + work_package_page.ensure_page_loaded + end + + it "allows picking another label and saving it" 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.submit_by_dashboard + + expect(work_package.reload.labels).to contain_exactly(label, other_label) + field.expect_state_text(other_label.name) + end + end +end diff --git a/spec/support/edit_fields/edit_field.rb b/spec/support/edit_fields/edit_field.rb index bfe5c5575bc5..397676a1df55 100644 --- a/spec/support/edit_fields/edit_field.rb +++ b/spec/support/edit_fields/edit_field.rb @@ -107,6 +107,14 @@ def expect_display_value(value) .to have_content(value) end + ## + # Checks that exactly the given values are currently selected in a + # multi-select field (ng-select in multiple mode), reading its value chips. + def expect_selected_values(*names) + expect(field_container).to have_css(".ng-value-label", count: names.size) + names.each { |name| expect(field_container).to have_css(".ng-value-label", text: name) } + end + ## # Activate the field and check it opened correctly # @return [EditField] self @@ -327,6 +335,8 @@ def derive_field_type "create-autocompleter" when :targetVersions "ng-select" + when :labels + "op-labels-autocompleter" when :project "op-project-autocompleter" when :activity From f492f4db50d0deefd593b12b8ac22bd56aa311b6 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 23 Sep 2026 23:09:53 +0300 Subject: [PATCH 02/12] Create labels from the work package labels dropdown The create option is pinned below the options and only offered while the typed name matches no loaded label. The created label joins the open selection and is saved with the field. --- config/locales/js-en.yml | 1 + frontend/src/app/core/apiv3/api-v3.service.ts | 3 + .../create-label-button.component.html | 15 +++ .../create-label-button.component.ts | 118 ++++++++++++++++++ ...bels-autocompleter-template.component.html | 3 + ...labels-autocompleter-template.component.ts | 41 ++++++ .../labels-autocompleter.component.ts | 22 +++- .../openproject-autocompleter.module.ts | 8 ++ .../inplace_editor/labels_editor_spec.rb | 22 ++++ 9 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 frontend/src/app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component.html create mode 100644 frontend/src/app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component.ts create mode 100644 frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component.html create mode 100644 frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component.ts diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index 5d500c186c3d..c4cc12c55ae6 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: + create_label: 'Create label "%{name}"' 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/create-label-button/create-label-button.component.html b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component.html new file mode 100644 index 000000000000..0295c7088d8e --- /dev/null +++ b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component.html @@ -0,0 +1,15 @@ +@if (autocompleter.loadedLabels$ | async; as items) { + @if (showCreateOption(items)) { + + } +} diff --git a/frontend/src/app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component.ts b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component.ts new file mode 100644 index 000000000000..ca82c4bbe16d --- /dev/null +++ b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component.ts @@ -0,0 +1,118 @@ +//-- 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, ChangeDetectorRef, Component, Input, inject } from '@angular/core'; +import { finalize } from 'rxjs/operators'; +import { HttpClient } from '@angular/common/http'; +import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service'; +import { + OpAutocompleterComponent, +} from 'core-app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component'; +import { + IApiLabel, + ILabelAutocompleteItem, + LabelsAutocompleterComponent, +} from 'core-app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component'; + +@Component({ + selector: 'op-create-label-button', + templateUrl: './create-label-button.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, +}) +export class CreateLabelButtonComponent { + @Input() searchTerm = ''; + + readonly I18n = inject(I18nService); + readonly http = inject(HttpClient); + readonly apiV3Service = inject(ApiV3Service); + readonly halNotification = inject(HalResourceNotificationService); + readonly cdRef = inject(ChangeDetectorRef); + readonly autocompleter = inject(OpAutocompleterComponent) as LabelsAutocompleterComponent; + + public creating = false; + + public get trimmedSearchTerm():string { + return (this.searchTerm ?? '').trim(); + } + + public buttonText():string { + return this.I18n.t('js.autocompleter.create_label', { name: this.trimmedSearchTerm }); + } + + public showCreateOption(items:ILabelAutocompleteItem[]|null):boolean { + const term = this.trimmedSearchTerm; + if (!term) { + return false; + } + + const normalizedTerm = this.normalize(term); + return !(items ?? []).some((item) => this.normalize(item.name) === normalizedTerm); + } + + public onCreateClick(event:Event):void { + event.stopPropagation(); + const name = this.trimmedSearchTerm; + if (!name || this.creating) { + return; + } + + this.creating = true; + + this + .http + .post(this.apiV3Service.labels.toString(), { name }) + .pipe( + finalize(() => { + this.creating = false; + this.cdRef.markForCheck(); + }), + ) + .subscribe({ + next: (label) => this.addLabel(label), + error: (error) => this.halNotification.handleRawError(error), + }); + } + + private addLabel(label:IApiLabel):void { + const created:ILabelAutocompleteItem = { id: label.id, name: label.name, href: label._links.self.href }; + const current = Array.isArray(this.autocompleter.model) ? this.autocompleter.model : []; + + if (!current.some((item) => item.href === created.href)) { + this.autocompleter.changed([...current, created]); + } + + this.autocompleter.ngSelectInstance.filter(''); + } + + private normalize(value:string):string { + return value.trim().replace(/\s+/g, ' ').toLowerCase(); + } +} 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..067223ac18fa --- /dev/null +++ b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component.html @@ -0,0 +1,3 @@ + + + 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..6fa2d8899007 --- /dev/null +++ b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component.ts @@ -0,0 +1,41 @@ +//-- 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 } from '@angular/core'; +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('footerTemplate') footerTemplate?:TemplateRef; +} 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 index d16cdcc1292a..441a675c3017 100644 --- 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 @@ -26,10 +26,10 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { keyBy } from 'lodash-es'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { map, tap } from 'rxjs/operators'; import { OpAutocompleterComponent, } from 'core-app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component'; @@ -37,6 +37,9 @@ import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter 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 { + LabelsAutocompleterTemplateComponent, +} from 'core-app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component'; export const labelsAutocompleterSelector = 'op-labels-autocompleter'; @@ -46,7 +49,7 @@ export interface ILabelAutocompleteItem { href:string|null; } -interface IApiLabel { +export interface IApiLabel { id:string|number; name:string; _links:{ self:{ href:string|null } }; @@ -58,9 +61,17 @@ interface IApiLabel { changeDetection: ChangeDetectionStrategy.OnPush, standalone: false, }) -export class LabelsAutocompleterComponent extends OpAutocompleterComponent { +export class LabelsAutocompleterComponent extends OpAutocompleterComponent implements OnInit { getOptionsFn = this.getLabels.bind(this); + // Latest options from the server; results$ is cold and re-runs the request per subscriber. + public readonly loadedLabels$ = new BehaviorSubject([]); + + ngOnInit():void { + super.ngOnInit(); + this.applyTemplates(LabelsAutocompleterTemplateComponent); + } + public getLabels(searchTerm?:string):Observable { const filterObject = keyBy(this.filters, 'name'); const filters = ApiV3FilterBuilder.fromFilterObject(filterObject); @@ -77,6 +88,7 @@ export class LabelsAutocompleterComponent extends OpAutocompleterComponent>(filteredURL.toString()) .pipe( map((res) => res._embedded.elements.map((label) => ({ id: label.id, name: label.name, href: label._links.self.href }))), + tap((labels) => this.loadedLabels$.next(labels)), ); } 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 6c9a1e8348d4..3d4ec43acb72 100644 --- a/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts +++ b/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts @@ -51,6 +51,12 @@ import { 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 { + CreateLabelButtonComponent, +} from 'core-app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component'; import { MeetingAutocompleterComponent, } from 'core-app/shared/components/autocompleter/meeting-autocompleter/meeting-autocompleter.component'; @@ -113,6 +119,8 @@ export const OPENPROJECT_AUTOCOMPLETE_COMPONENTS = [ UserAutocompleterComponent, UserAutocompleterTemplateComponent, LabelsAutocompleterComponent, + LabelsAutocompleterTemplateComponent, + CreateLabelButtonComponent, ResourceAllocationAutocompleterComponent, ResourceAllocationAutocompleterTemplateComponent, MeetingAutocompleterTemplateComponent, 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 index b1f149bab2cd..6b2d404ec951 100644 --- a/spec/features/work_packages/details/inplace_editor/labels_editor_spec.rb +++ b/spec/features/work_packages/details/inplace_editor/labels_editor_spec.rb @@ -43,6 +43,28 @@ expect(field.field_container).to have_no_text(label.name) expect(work_package.reload.labels).to contain_exactly(other_label) end + + it "offers to create a new label from the search term and adds it to the selection" do + new_label_name = "Urgent" + create_button_text = I18n.t("js.autocompleter.create_label", name: new_label_name) + duplicate_button_text = I18n.t("js.autocompleter.create_label", name: other_label.name.upcase) + + field.activate! + + dropdown = field.autocomplete(other_label.name.upcase, select: false) + expect(dropdown).to have_no_button(duplicate_button_text) + + dropdown = field.autocomplete(new_label_name, select: false) + expect(dropdown).to have_button(create_button_text) + dropdown.click_button(create_button_text) + + 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 end context "in the split view" do From 6046f8fc5d00733b068f665b1a241672bf2c15ea Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 23 Sep 2026 23:33:39 +0300 Subject: [PATCH 03/12] Announce multi-value display fields by name The accessible name of the field stringified the HAL resources; it now joins the names like the title does. The create-label option also stays hidden while results are still loading. --- .../create-label-button.component.html | 2 +- .../resources-display-field.module.spec.ts | 73 +++++++++++++++++++ .../resources-display-field.module.ts | 4 + 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/shared/components/fields/display/field-types/resources-display-field.module.spec.ts diff --git a/frontend/src/app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component.html b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component.html index 0295c7088d8e..7353e5a787f0 100644 --- a/frontend/src/app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component.html +++ b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component.html @@ -1,5 +1,5 @@ @if (autocompleter.loadedLabels$ | async; as items) { - @if (showCreateOption(items)) { + @if ((autocompleter.loading$ | async) !== true && showCreateOption(items)) { - } -} diff --git a/frontend/src/app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component.ts b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component.ts deleted file mode 100644 index ca82c4bbe16d..000000000000 --- a/frontend/src/app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component.ts +++ /dev/null @@ -1,118 +0,0 @@ -//-- 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, ChangeDetectorRef, Component, Input, inject } from '@angular/core'; -import { finalize } from 'rxjs/operators'; -import { HttpClient } from '@angular/common/http'; -import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; -import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service'; -import { - OpAutocompleterComponent, -} from 'core-app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component'; -import { - IApiLabel, - ILabelAutocompleteItem, - LabelsAutocompleterComponent, -} from 'core-app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component'; - -@Component({ - selector: 'op-create-label-button', - templateUrl: './create-label-button.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: false, -}) -export class CreateLabelButtonComponent { - @Input() searchTerm = ''; - - readonly I18n = inject(I18nService); - readonly http = inject(HttpClient); - readonly apiV3Service = inject(ApiV3Service); - readonly halNotification = inject(HalResourceNotificationService); - readonly cdRef = inject(ChangeDetectorRef); - readonly autocompleter = inject(OpAutocompleterComponent) as LabelsAutocompleterComponent; - - public creating = false; - - public get trimmedSearchTerm():string { - return (this.searchTerm ?? '').trim(); - } - - public buttonText():string { - return this.I18n.t('js.autocompleter.create_label', { name: this.trimmedSearchTerm }); - } - - public showCreateOption(items:ILabelAutocompleteItem[]|null):boolean { - const term = this.trimmedSearchTerm; - if (!term) { - return false; - } - - const normalizedTerm = this.normalize(term); - return !(items ?? []).some((item) => this.normalize(item.name) === normalizedTerm); - } - - public onCreateClick(event:Event):void { - event.stopPropagation(); - const name = this.trimmedSearchTerm; - if (!name || this.creating) { - return; - } - - this.creating = true; - - this - .http - .post(this.apiV3Service.labels.toString(), { name }) - .pipe( - finalize(() => { - this.creating = false; - this.cdRef.markForCheck(); - }), - ) - .subscribe({ - next: (label) => this.addLabel(label), - error: (error) => this.halNotification.handleRawError(error), - }); - } - - private addLabel(label:IApiLabel):void { - const created:ILabelAutocompleteItem = { id: label.id, name: label.name, href: label._links.self.href }; - const current = Array.isArray(this.autocompleter.model) ? this.autocompleter.model : []; - - if (!current.some((item) => item.href === created.href)) { - this.autocompleter.changed([...current, created]); - } - - this.autocompleter.ngSelectInstance.filter(''); - } - - private normalize(value:string):string { - return value.trim().replace(/\s+/g, ' ').toLowerCase(); - } -} 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 index 067223ac18fa..aa5d65e6afa4 100644 --- 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 @@ -1,3 +1,3 @@ - - + + {{ createLabelText(search) }} 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 index 6fa2d8899007..44b3ab93b09b 100644 --- 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 @@ -26,7 +26,8 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { ChangeDetectionStrategy, Component, TemplateRef, ViewChild } from '@angular/core'; +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'; @@ -37,5 +38,11 @@ import { standalone: false, }) export class LabelsAutocompleterTemplateComponent implements IAutocompleterTemplateComponent { - @ViewChild('footerTemplate') footerTemplate?:TemplateRef; + @ViewChild('tagTemplate') tagTemplate?:TemplateRef; + + readonly I18n = inject(I18nService); + + public createLabelText(searchTerm:string):string { + return this.I18n.t('js.autocompleter.create_label', { name: (searchTerm ?? '').trim() }); + } } 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..44b5d7c3edb8 --- /dev/null +++ b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component.spec.ts @@ -0,0 +1,141 @@ +//-- 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 { States } from 'core-app/core/states/states.service'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { ToastService } from 'core-app/shared/components/toaster/toast.service'; +import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service'; +import { LabelsAutocompleterComponent } from './labels-autocompleter.component'; + +describe('LabelsAutocompleterComponent', () => { + let component:LabelsAutocompleterComponent; + let httpMock:HttpTestingController; + let toast:{ addError:ReturnType }; + let halNotification:{ handleRawError:ReturnType }; + + const i18nStub = { t: (key:string) => key }; + + beforeEach(async () => { + toast = { addError: vi.fn() }; + halNotification = { handleRawError: vi.fn() }; + + await TestBed.configureTestingModule({ + declarations: [LabelsAutocompleterComponent], + imports: [NgSelectModule], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + States, + provideHttpClient(withXhr(), withInterceptorsFromDi()), + provideHttpClientTesting(), + { provide: I18nService, useValue: i18nStub }, + { provide: ToastService, useValue: toast }, + { provide: HalResourceNotificationService, useValue: halNotification }, + ], + }).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('shows a specific message and rejects on a 403', async () => { + const promise = component.createLabel('Urgent'); + + pendingRequest().flush({ _type: 'Error' }, { status: 403, statusText: 'Forbidden' }); + + await expect(promise).rejects.toBeTruthy(); + expect(toast.addError).toHaveBeenCalledWith('js.autocompleter.create_label_forbidden'); + expect(halNotification.handleRawError).not.toHaveBeenCalled(); + }); + + it('delegates other errors to the generic handler', async () => { + const promise = component.createLabel('Urgent'); + + pendingRequest().flush({ _type: 'Error' }, { status: 422, statusText: 'Unprocessable Entity' }); + + await expect(promise).rejects.toBeTruthy(); + expect(halNotification.handleRawError).toHaveBeenCalled(); + expect(toast.addError).not.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('assigns addTag on init without throwing change-detection errors', () => { + const fixture = TestBed.createComponent(LabelsAutocompleterComponent); + expect(() => fixture.detectChanges()).not.toThrow(); + expect(typeof fixture.componentInstance.addTag).toBe('function'); + }); +}); 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 index 441a675c3017..67f3f39f0da7 100644 --- 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 @@ -26,10 +26,11 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit, inject } from '@angular/core'; import { keyBy } from 'lodash-es'; -import { BehaviorSubject, Observable } from 'rxjs'; -import { map, tap } from 'rxjs/operators'; +import { firstValueFrom, Observable, throwError } from 'rxjs'; +import { catchError, finalize, map } from 'rxjs/operators'; +import { HttpErrorResponse } from '@angular/common/http'; import { OpAutocompleterComponent, } from 'core-app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component'; @@ -37,6 +38,8 @@ import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter 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 { ToastService } from 'core-app/shared/components/toaster/toast.service'; +import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service'; import { LabelsAutocompleterTemplateComponent, } from 'core-app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component'; @@ -64,12 +67,16 @@ export interface IApiLabel { export class LabelsAutocompleterComponent extends OpAutocompleterComponent implements OnInit { getOptionsFn = this.getLabels.bind(this); - // Latest options from the server; results$ is cold and re-runs the request per subscriber. - public readonly loadedLabels$ = new BehaviorSubject([]); + readonly toastService = inject(ToastService); + + readonly halNotification = inject(HalResourceNotificationService); + + private creatingLabel = false; ngOnInit():void { super.ngOnInit(); this.applyTemplates(LabelsAutocompleterTemplateComponent); + this.addTag = this.createLabel.bind(this); } public getLabels(searchTerm?:string):Observable { @@ -88,10 +95,37 @@ export class LabelsAutocompleterComponent extends OpAutocompleterComponent>(filteredURL.toString()) .pipe( map((res) => res._embedded.elements.map((label) => ({ id: label.id, name: label.name, href: label._links.self.href }))), - tap((labels) => this.loadedLabels$.next(labels)), ); } + 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) => { + if (error instanceof HttpErrorResponse && error.status === 403) { + this.toastService.addError(this.I18n.t('js.autocompleter.create_label_forbidden')); + } else { + this.halNotification.handleRawError(error); + } + + return throwError(() => error); + }), + finalize(() => { this.creatingLabel = false; }), + ), + ); + } + protected defaultCompareWithFunction():(a:unknown, b:unknown) => boolean { return compareByAttribute('href', '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 3d4ec43acb72..7ff14f10d9c6 100644 --- a/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts +++ b/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts @@ -54,9 +54,6 @@ import { import { LabelsAutocompleterTemplateComponent, } from 'core-app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component'; -import { - CreateLabelButtonComponent, -} from 'core-app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component'; import { MeetingAutocompleterComponent, } from 'core-app/shared/components/autocompleter/meeting-autocompleter/meeting-autocompleter.component'; @@ -120,7 +117,6 @@ export const OPENPROJECT_AUTOCOMPLETE_COMPONENTS = [ UserAutocompleterTemplateComponent, LabelsAutocompleterComponent, LabelsAutocompleterTemplateComponent, - CreateLabelButtonComponent, ResourceAllocationAutocompleterComponent, ResourceAllocationAutocompleterTemplateComponent, MeetingAutocompleterTemplateComponent, 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 index 6b2d404ec951..b750d3d8a4d5 100644 --- a/spec/features/work_packages/details/inplace_editor/labels_editor_spec.rb +++ b/spec/features/work_packages/details/inplace_editor/labels_editor_spec.rb @@ -6,7 +6,7 @@ require "support/edit_fields/edit_field" require "features/work_packages/work_packages_page" -RSpec.describe "labels inplace editor", :js, with_flag: :work_package_labels do +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") } @@ -30,7 +30,7 @@ work_package_page.ensure_page_loaded end - it "allows picking a label and removing another in the same edit, saving in one request" do + it "allows picking a label and removing another in the same edit" do field.expect_state_text(label.name) field.activate! @@ -44,19 +44,21 @@ expect(work_package.reload.labels).to contain_exactly(other_label) end - it "offers to create a new label from the search term and adds it to the selection" do + it "creates a new label from the search term via the keyboard and adds it to the selection" do new_label_name = "Urgent" - create_button_text = I18n.t("js.autocompleter.create_label", name: new_label_name) - duplicate_button_text = I18n.t("js.autocompleter.create_label", name: other_label.name.upcase) + create_option_text = I18n.t("js.autocompleter.create_label", name: new_label_name) + duplicate_option_text = I18n.t("js.autocompleter.create_label", name: other_label.name.upcase) field.activate! dropdown = field.autocomplete(other_label.name.upcase, select: false) - expect(dropdown).to have_no_button(duplicate_button_text) + 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) dropdown = field.autocomplete(new_label_name, select: false) - expect(dropdown).to have_button(create_button_text) - dropdown.click_button(create_button_text) + 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 @@ -65,6 +67,30 @@ 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 "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 @@ -76,16 +102,11 @@ work_package_page.ensure_page_loaded end - it "allows picking another label and saving it" do + it "renders and shows the current labels when activated" 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.submit_by_dashboard - - expect(work_package.reload.labels).to contain_exactly(label, other_label) - field.expect_state_text(other_label.name) + field.expect_selected_values(label.name) end end end diff --git a/spec/support/edit_fields/edit_field.rb b/spec/support/edit_fields/edit_field.rb index 397676a1df55..ddfa3db67f1a 100644 --- a/spec/support/edit_fields/edit_field.rb +++ b/spec/support/edit_fields/edit_field.rb @@ -112,7 +112,7 @@ def expect_display_value(value) # multi-select field (ng-select in multiple mode), reading its value chips. def expect_selected_values(*names) expect(field_container).to have_css(".ng-value-label", count: names.size) - names.each { |name| expect(field_container).to have_css(".ng-value-label", text: name) } + names.each { expect(field_container).to have_css(".ng-value-label", text: it) } end ## From 40000f56e5e958d7d5912497e5a513f81df0c87d Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Mon, 28 Sep 2026 15:21:11 +0300 Subject: [PATCH 05/12] Hide label edit controls without an autocompleter URL Save/Cancel render only alongside the input, and the combobox is announced by the field name. --- .../field-types/labels-edit-field.component.html | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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 index 69d7a228f633..e9f0556b1000 100644 --- 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 @@ -7,6 +7,7 @@ [dropdownPosition]="'top'" [disabled]="inFlight" bindLabel="name" + [ariaLabel]="schema.name" [focusDirectly]="!(handler.inEditMode || isNew)" [openDirectly]="!(handler.inEditMode || isNew)" appendTo="body" @@ -18,12 +19,12 @@ (add)="labelsAutocompleter.repositionDropdown()" (remove)="labelsAutocompleter.repositionDropdown()" /> -} -@if (!handler.inEditMode) { - + @if (!handler.inEditMode) { + + } } From 5af7db1f73f836f3c4bd195b1ccd04d84663d570 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Mon, 28 Sep 2026 19:51:27 +0300 Subject: [PATCH 06/12] Leave label create errors to the generic handler The API authorizes label creation, so the client no longer special-cases 403 responses. --- config/locales/js-en.yml | 1 - .../labels-autocompleter.component.spec.ts | 17 +---------------- .../labels-autocompleter.component.ts | 10 +--------- 3 files changed, 2 insertions(+), 26 deletions(-) diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index ae218493b0d5..c4cc12c55ae6 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -80,7 +80,6 @@ en: remove: "Remove %{name}" autocompleter: create_label: 'Create label "%{name}"' - create_label_forbidden: "You are not allowed to create labels." notFoundText: "No items found" placeholder: "Type to search" project: 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 index 44b5d7c3edb8..69ec53788ed9 100644 --- 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 @@ -33,20 +33,17 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common import { NgSelectModule } from '@ng-select/ng-select'; import { States } from 'core-app/core/states/states.service'; import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { ToastService } from 'core-app/shared/components/toaster/toast.service'; import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service'; import { LabelsAutocompleterComponent } from './labels-autocompleter.component'; describe('LabelsAutocompleterComponent', () => { let component:LabelsAutocompleterComponent; let httpMock:HttpTestingController; - let toast:{ addError:ReturnType }; let halNotification:{ handleRawError:ReturnType }; const i18nStub = { t: (key:string) => key }; beforeEach(async () => { - toast = { addError: vi.fn() }; halNotification = { handleRawError: vi.fn() }; await TestBed.configureTestingModule({ @@ -58,7 +55,6 @@ describe('LabelsAutocompleterComponent', () => { provideHttpClient(withXhr(), withInterceptorsFromDi()), provideHttpClientTesting(), { provide: I18nService, useValue: i18nStub }, - { provide: ToastService, useValue: toast }, { provide: HalResourceNotificationService, useValue: halNotification }, ], }).compileComponents(); @@ -87,24 +83,13 @@ describe('LabelsAutocompleterComponent', () => { await expect(promise).resolves.toEqual({ id: 5, name: 'Urgent', href: '/api/v3/labels/5' }); }); - it('shows a specific message and rejects on a 403', async () => { - const promise = component.createLabel('Urgent'); - - pendingRequest().flush({ _type: 'Error' }, { status: 403, statusText: 'Forbidden' }); - - await expect(promise).rejects.toBeTruthy(); - expect(toast.addError).toHaveBeenCalledWith('js.autocompleter.create_label_forbidden'); - expect(halNotification.handleRawError).not.toHaveBeenCalled(); - }); - - it('delegates other errors to the generic handler', async () => { + 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(); - expect(toast.addError).not.toHaveBeenCalled(); }); it('ignores a second call while a request is in flight, then allows a retry', async () => { 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 index 67f3f39f0da7..0518ad3ece52 100644 --- 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 @@ -30,7 +30,6 @@ import { ChangeDetectionStrategy, Component, OnInit, inject } from '@angular/cor import { keyBy } from 'lodash-es'; import { firstValueFrom, Observable, throwError } from 'rxjs'; import { catchError, finalize, map } from 'rxjs/operators'; -import { HttpErrorResponse } from '@angular/common/http'; import { OpAutocompleterComponent, } from 'core-app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component'; @@ -38,7 +37,6 @@ import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter 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 { ToastService } from 'core-app/shared/components/toaster/toast.service'; import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service'; import { LabelsAutocompleterTemplateComponent, @@ -67,8 +65,6 @@ export interface IApiLabel { export class LabelsAutocompleterComponent extends OpAutocompleterComponent implements OnInit { getOptionsFn = this.getLabels.bind(this); - readonly toastService = inject(ToastService); - readonly halNotification = inject(HalResourceNotificationService); private creatingLabel = false; @@ -113,11 +109,7 @@ export class LabelsAutocompleterComponent extends OpAutocompleterComponent ({ id: label.id, name: label.name, href: label._links.self.href })), catchError((error:unknown) => { - if (error instanceof HttpErrorResponse && error.status === 403) { - this.toastService.addError(this.I18n.t('js.autocompleter.create_label_forbidden')); - } else { - this.halNotification.handleRawError(error); - } + this.halNotification.handleRawError(error); return throwError(() => error); }), From 7c4b670d314e5124ebabbe054732b4fabe90a5ad Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Mon, 28 Sep 2026 19:51:35 +0300 Subject: [PATCH 07/12] Match the labels dropdown to the assignee autocompleter New labels read " (New label)", matches highlight in blue, and the list closes on pick so it can open either way without covering Save/Cancel. --- config/locales/js-en.yml | 2 +- .../labels-autocompleter-template.component.html | 9 +++++++-- .../labels-autocompleter-template.component.ts | 8 +++----- .../edit/field-types/labels-edit-field.component.html | 3 --- .../details/inplace_editor/labels_editor_spec.rb | 5 +++-- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index c4cc12c55ae6..de421414ab1e 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -79,7 +79,7 @@ en: single: 'Select "%{name}"' remove: "Remove %{name}" autocompleter: - create_label: 'Create label "%{name}"' + new_label: "(New label)" notFoundText: "No items found" placeholder: "Type to search" project: 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 index aa5d65e6afa4..499cb73f7dd6 100644 --- 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 @@ -1,3 +1,8 @@ - - {{ createLabelText(search) }} + + {{ 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 index 44b3ab93b09b..27d517797352 100644 --- 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 @@ -38,11 +38,9 @@ import { standalone: false, }) export class LabelsAutocompleterTemplateComponent implements IAutocompleterTemplateComponent { - @ViewChild('tagTemplate') tagTemplate?:TemplateRef; + @ViewChild('optionTemplate') optionTemplate?:TemplateRef; - readonly I18n = inject(I18nService); + @ViewChild('tagTemplate') tagTemplate?:TemplateRef; - public createLabelText(searchTerm:string):string { - return this.I18n.t('js.autocompleter.create_label', { name: (searchTerm ?? '').trim() }); - } + readonly newLabelSuffix = inject(I18nService).t('js.autocompleter.new_label'); } 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 index e9f0556b1000..be8d9e5b66c2 100644 --- 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 @@ -2,9 +2,7 @@ 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 index b750d3d8a4d5..f2761211364f 100644 --- a/spec/features/work_packages/details/inplace_editor/labels_editor_spec.rb +++ b/spec/features/work_packages/details/inplace_editor/labels_editor_spec.rb @@ -46,8 +46,9 @@ it "creates a new label from the search term via the keyboard and adds it to the selection" do new_label_name = "Urgent" - create_option_text = I18n.t("js.autocompleter.create_label", name: new_label_name) - duplicate_option_text = I18n.t("js.autocompleter.create_label", name: other_label.name.upcase) + new_label_suffix = I18n.t("js.autocompleter.new_label") + create_option_text = "#{new_label_name} #{new_label_suffix}" + duplicate_option_text = "#{other_label.name.upcase} #{new_label_suffix}" field.activate! From 049e09cb907007617888c340112d3753745b778d Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Tue, 29 Sep 2026 10:03:27 +0300 Subject: [PATCH 08/12] Pin the create label option to the bottom of the dropdown The create option stays fixed while the list scrolls and remains a keyboard-reachable option. --- ...bels-autocompleter-template.component.html | 4 ++- .../labels-autocompleter.component.ts | 35 +++++++++++++++++++ .../global_styles/content/_autocomplete.sass | 13 +++++++ 3 files changed, 51 insertions(+), 1 deletion(-) 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 index 499cb73f7dd6..a9e332af2b19 100644 --- 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 @@ -5,4 +5,6 @@ >{{ item.name }} -{{ search }} {{ newLabelSuffix }} + + {{ search }} {{ newLabelSuffix }} + 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 index 0518ad3ece52..6e5044151a33 100644 --- 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 @@ -65,6 +65,12 @@ export interface IApiLabel { 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); private creatingLabel = false; @@ -75,6 +81,35 @@ export class LabelsAutocompleterComponent extends OpAutocompleterComponent 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); 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 From 6fe4394937bec6c8f461a36f2bd21813a9d11630 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Tue, 29 Sep 2026 11:52:22 +0300 Subject: [PATCH 09/12] Require edit rights for labels on existing work packages The shared schema no longer treats add_work_packages as enough to edit labels, so add-only users see the field read-only. --- .../schema/typed_schema_contract.rb | 1 + .../work_packages_schemas_resource_spec.rb | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+) 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/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..4556e6fd2887 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,28 @@ end end + context "when checking whether labels are writable", with_flag: { work_package_labels: true } do + let(:role) do + create(:project_role, permissions: %i[view_work_packages add_work_packages change_work_package_status]) + end + + it "is false without edit_work_packages" do + expect(last_response.body) + .to be_json_eql(false.to_json) + .at_path("labels/writable") + end + + context "with edit_work_packages" do + let(:role) { create(:project_role, permissions: %i[view_work_packages edit_work_packages]) } + + it "is true" 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" } From 0936db99fb891d17ffae25bda90d845409309f50 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Tue, 29 Sep 2026 11:52:25 +0300 Subject: [PATCH 10/12] Offer label creation only to users who can edit work packages Add-only users pick existing labels; creating new ones on the fly needs edit_work_packages in the project. --- .../labels-autocompleter.component.spec.ts | 28 ++++++++- .../labels-autocompleter.component.ts | 31 ++++++++-- .../labels-edit-field.component.html | 1 + .../inplace_editor/labels_editor_spec.rb | 58 ++++++++++++++++++- 4 files changed, 112 insertions(+), 6 deletions(-) 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 index 69ec53788ed9..6a00a7e385fe 100644 --- 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 @@ -31,20 +31,24 @@ 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], @@ -56,6 +60,7 @@ describe('LabelsAutocompleterComponent', () => { provideHttpClientTesting(), { provide: I18nService, useValue: i18nStub }, { provide: HalResourceNotificationService, useValue: halNotification }, + { provide: CurrentUserService, useValue: currentUser }, ], }).compileComponents(); @@ -118,9 +123,30 @@ describe('LabelsAutocompleterComponent', () => { await retry; }); - it('assigns addTag on init without throwing change-detection errors', () => { + 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 index 6e5044151a33..282ce159e2d0 100644 --- 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 @@ -26,10 +26,10 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { ChangeDetectionStrategy, Component, OnInit, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input, OnInit, inject } from '@angular/core'; import { keyBy } from 'lodash-es'; -import { firstValueFrom, Observable, throwError } from 'rxjs'; -import { catchError, finalize, map } from 'rxjs/operators'; +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'; @@ -38,6 +38,7 @@ import { addFiltersToPath } from 'core-app/core/apiv3/helpers/add-filters-to-pat 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'; @@ -73,12 +74,34 @@ export class LabelsAutocompleterComponent extends OpAutocompleterComponent(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.addTag = this.createLabel.bind(this); + + 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 { 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 index be8d9e5b66c2..cd9ef7d53a36 100644 --- 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 @@ -1,6 +1,7 @@ @if (url) { %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 From 3a31f2e0de28ab810711982129b413bb2c04c9d0 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Tue, 29 Sep 2026 13:43:26 +0300 Subject: [PATCH 11/12] Tidy the labels specs Name the permission each schema case covers and check the case-insensitive duplicate on its own. --- .../inplace_editor/labels_editor_spec.rb | 16 ++++++++++----- .../work_packages_schemas_resource_spec.rb | 20 ++++++++++--------- spec/support/edit_fields/edit_field.rb | 3 --- 3 files changed, 22 insertions(+), 17 deletions(-) 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 index b987f5fe41b6..428d64cdd756 100644 --- a/spec/features/work_packages/details/inplace_editor/labels_editor_spec.rb +++ b/spec/features/work_packages/details/inplace_editor/labels_editor_spec.rb @@ -48,14 +48,9 @@ new_label_name = "Urgent" new_label_suffix = I18n.t("js.autocompleter.new_label") create_option_text = "#{new_label_name} #{new_label_suffix}" - 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) - dropdown = field.autocomplete(new_label_name, select: false) expect(dropdown).to have_selector(:list_box_option, text: create_option_text) @@ -69,6 +64,17 @@ 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) 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 4556e6fd2887..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,21 +162,23 @@ end end - context "when checking whether labels are writable", with_flag: { work_package_labels: true } do - let(:role) do - create(:project_role, permissions: %i[view_work_packages add_work_packages change_work_package_status]) - 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 "is false without edit_work_packages" do - expect(last_response.body) - .to be_json_eql(false.to_json) - .at_path("labels/writable") + 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 "is true" do + it "marks labels as writable" do expect(last_response.body) .to be_json_eql(true.to_json) .at_path("labels/writable") diff --git a/spec/support/edit_fields/edit_field.rb b/spec/support/edit_fields/edit_field.rb index ddfa3db67f1a..68230f028517 100644 --- a/spec/support/edit_fields/edit_field.rb +++ b/spec/support/edit_fields/edit_field.rb @@ -107,9 +107,6 @@ def expect_display_value(value) .to have_content(value) end - ## - # Checks that exactly the given values are currently selected in a - # multi-select field (ng-select in multiple mode), reading its value chips. 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) } From 456a3ea800902155c76d6950a89c5e2ab7e560b3 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Tue, 29 Sep 2026 13:43:30 +0300 Subject: [PATCH 12/12] Cover version fields in the multi-value display name The joined name backs the title and aria-label of every multi-value resource field, not only labels. --- .../resources-display-field.module.spec.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) 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 index 98b9dd3496eb..f0600e88c89f 100644 --- 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 @@ -43,31 +43,37 @@ describe('ResourcesDisplayField', () => { get: (token:unknown, notFoundValue?:unknown) => serviceMap.get(token) ?? notFoundValue ?? {}, }; - function buildField(values:string[]):ResourcesDisplayField { + function buildField(name:string, type:string, values:string[]):ResourcesDisplayField { const resource = { - labels: values.map((name) => ({ name })), + [name]: values.map((value) => ({ name: value })), } as unknown as HalResource; - const field = new ResourcesDisplayField('labels', { + const field = new ResourcesDisplayField(name, { injector: mockInjector, container: 'single-view', options: {}, } as unknown as DisplayFieldContext); - field.apply(resource, { type: '[]Label' } as IFieldSchema); + field.apply(resource, { type } as IFieldSchema); return field; } it('joins the resource names with a comma for valueString', () => { - const field = buildField(['Bug', 'Feature', 'Needs review']); + 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([]); + 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'); + }); });