From 5f850907ee4cd0c185997a32d857594e21f3d2fa Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 23 Sep 2026 22:39:10 +0300 Subject: [PATCH 01/14] 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 74b6694409e0..505a1da92caf 100644 --- a/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts +++ b/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts @@ -47,6 +47,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'; @@ -108,6 +111,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 afc7199811d4..291759afd66b 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 @@ -113,7 +113,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 f7bda7b9cdaf..7253ffe839eb 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 @@ -83,6 +83,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) { @@ -94,6 +97,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 83b3c605a6fc..8ea2063414ce 100644 --- a/frontend/src/app/shared/components/fields/openproject-fields.module.ts +++ b/frontend/src/app/shared/components/fields/openproject-fields.module.ts @@ -63,6 +63,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 { DateTimeEditFieldComponent } from './edit/field-types/datetime-edit-field.component'; @@ -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 dab25cf30ec9..7be045cdf378 100644 --- a/spec/support/edit_fields/edit_field.rb +++ b/spec/support/edit_fields/edit_field.rb @@ -135,6 +135,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 @@ -355,6 +363,8 @@ def derive_field_type "create-autocompleter" when :targetVersions "ng-select" + when :labels + "op-labels-autocompleter" when :project "op-project-autocompleter" when :activity From d8a59b3fd30cece85dd560a0fe4e33229bc06f7a Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 23 Sep 2026 23:09:53 +0300 Subject: [PATCH 02/14] 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 d6708cbadd34..58221453140b 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 43bd778a5979..2061c81607a9 100644 --- a/frontend/src/app/core/apiv3/api-v3.service.ts +++ b/frontend/src/app/core/apiv3/api-v3.service.ts @@ -174,6 +174,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 505a1da92caf..a9fbab9fd1fb 100644 --- a/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts +++ b/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts @@ -50,6 +50,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'; @@ -112,6 +118,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 fd170569307b44158fd7e033ce1533754f053877 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 23 Sep 2026 23:33:39 +0300 Subject: [PATCH 03/14] 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 05c41e10f211..b28f1044c8ba 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 @@ -320,6 +321,8 @@ export class OpAutocompleterComponent; + tagTemplate:TemplateRef; + readonly opAutocompleterService = inject(OpAutocompleterService); ngOnInit() { @@ -557,7 +560,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 a9fbab9fd1fb..65db2f516538 100644 --- a/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts +++ b/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts @@ -53,9 +53,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'; @@ -119,7 +116,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 7be045cdf378..81dab9d80f7e 100644 --- a/spec/support/edit_fields/edit_field.rb +++ b/spec/support/edit_fields/edit_field.rb @@ -140,7 +140,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 fd81337dc118a7da046bf6457bc6a9dae50445f9 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Mon, 28 Sep 2026 15:21:11 +0300 Subject: [PATCH 05/14] 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 05f9ee4c5af3aa372eee5cf4a15685557d5c6c76 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Mon, 28 Sep 2026 19:51:27 +0300 Subject: [PATCH 06/14] 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 458605f8186b..58221453140b 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 985675b0b1c08beb180a4804c1e0e57f4447e9e4 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Mon, 28 Sep 2026 19:51:35 +0300 Subject: [PATCH 07/14] 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 58221453140b..79fa1822b827 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 a0add9b90a0a4dea41e9f5f64242b4a4f3e704cf Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Tue, 29 Sep 2026 10:03:27 +0300 Subject: [PATCH 08/14] 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 c8d08fc90422..fc500ef4c19c 100644 --- a/frontend/src/global_styles/content/_autocomplete.sass +++ b/frontend/src/global_styles/content/_autocomplete.sass @@ -200,6 +200,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 e41006074a99741c9dd78fff9962f27c88a97604 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Tue, 29 Sep 2026 11:52:22 +0300 Subject: [PATCH 09/14] 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 ff64b46b1dfb..5f35adb86c6c 100644 --- a/lib/api/v3/work_packages/schema/typed_schema_contract.rb +++ b/lib/api/v3/work_packages/schema/typed_schema_contract.rb @@ -36,6 +36,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 7e87d0825f5a..0085f5ef45e4 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 @@ -170,6 +170,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 02ef170774d150fe972407b6320235c948ea8a36 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Tue, 29 Sep 2026 11:52:25 +0300 Subject: [PATCH 10/14] 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 5a54f77cc0822377464a4e20d06f21679c3e7349 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Tue, 29 Sep 2026 13:43:26 +0300 Subject: [PATCH 11/14] 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 0085f5ef45e4..05b872728e1c 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 @@ -170,21 +170,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 81dab9d80f7e..1ac0d7828cb6 100644 --- a/spec/support/edit_fields/edit_field.rb +++ b/spec/support/edit_fields/edit_field.rb @@ -135,9 +135,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 229e94346a010de995b396860e670bb6123632bb Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Tue, 29 Sep 2026 13:43:30 +0300 Subject: [PATCH 12/14] 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'); + }); }); From 661a12a6af8b916283038cff96d308a61fd33e21 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Tue, 20 Oct 2026 08:30:05 +0300 Subject: [PATCH 13/14] Render the create label option like other add-tag options The option now uses ng-select's default tag template with a leading "Create label" prefix, matching the share dialog's invite option. The pinned row, its grey marked background and the keyboard scroll nudge are gone; the option is a plain last row in the list. Also refreshes the copyright headers of the labels autocompleter files to the current wording so eslint accepts them. --- config/locales/js-en.yml | 2 +- ...bels-autocompleter-template.component.html | 4 -- ...labels-autocompleter-template.component.ts | 10 +---- .../labels-autocompleter.component.spec.ts | 3 +- .../labels-autocompleter.component.ts | 38 +------------------ .../global_styles/content/_autocomplete.sass | 13 ------- .../inplace_editor/labels_editor_spec.rb | 23 +++++------ 7 files changed, 16 insertions(+), 77 deletions(-) diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index 79fa1822b827..dd9f21ff1fa6 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: - new_label: "(New label)" + create_label: "Create 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 a9e332af2b19..5154f56ee41c 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 @@ -4,7 +4,3 @@ [opSearchHighlight]="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 27d517797352..1141f73b97c5 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 @@ -20,14 +20,12 @@ // 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. +// along with this program. If not, see . // // 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 { ChangeDetectionStrategy, Component, TemplateRef, ViewChild } from '@angular/core'; import { IAutocompleterTemplateComponent, } from 'core-app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component'; @@ -39,8 +37,4 @@ import { }) 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 index 6a00a7e385fe..b28f87c09bc4 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 @@ -20,8 +20,7 @@ // 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. +// along with this program. If not, see . // // See COPYRIGHT and LICENSE files for more details. //++ 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 282ce159e2d0..d22b2e0b834f 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 @@ -20,8 +20,7 @@ // 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. +// along with this program. If not, see . // // See COPYRIGHT and LICENSE files for more details. //++ @@ -66,11 +65,7 @@ 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; + addTagText = this.I18n.t('js.autocompleter.create_label'); readonly halNotification = inject(HalResourceNotificationService); @@ -104,35 +99,6 @@ 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 fc500ef4c19c..c8d08fc90422 100644 --- a/frontend/src/global_styles/content/_autocomplete.sass +++ b/frontend/src/global_styles/content/_autocomplete.sass @@ -200,19 +200,6 @@ 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/spec/features/work_packages/details/inplace_editor/labels_editor_spec.rb b/spec/features/work_packages/details/inplace_editor/labels_editor_spec.rb index 428d64cdd756..ee608019ec55 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 @@ -20,6 +20,12 @@ login_as(user) end + def create_option_text(name) + prefix = I18n.t("js.autocompleter.create_label") + + /#{Regexp.escape(prefix)}\s*"#{Regexp.escape(name)}"/ + end + context "in the full view" do let(:work_package_page) { Pages::FullWorkPackage.new(work_package) } let(:field) { work_package_page.edit_field(:labels) } @@ -46,13 +52,11 @@ 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) + expect(dropdown).to have_selector(:list_box_option, text: create_option_text(new_label_name)) field.autocomplete_selector.send_keys(:return) @@ -65,14 +69,11 @@ 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) + expect(dropdown).to have_no_selector(:list_box_option, text: create_option_text(other_label.name.upcase)) end it "discards the selection when the edit is cancelled" do @@ -132,8 +133,6 @@ 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) @@ -141,7 +140,7 @@ 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}") + expect(dropdown).to have_no_selector(:list_box_option, text: create_option_text("Unknown")) end end @@ -153,11 +152,9 @@ 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) + expect(dropdown).to have_selector(:list_box_option, text: create_option_text(new_label_name)) field.autocomplete_selector.send_keys(:return) From 63b316ef4a43428c3845eb0abdebc7e14a313b3d Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Tue, 20 Oct 2026 10:05:57 +0300 Subject: [PATCH 14/14] Refresh copyright headers in the labels field files --- .../display/field-types/resources-display-field.module.spec.ts | 3 +-- .../fields/edit/field-types/labels-edit-field.component.ts | 3 +-- 2 files changed, 2 insertions(+), 4 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 f0600e88c89f..a77541470630 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 @@ -20,8 +20,7 @@ // 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. +// along with this program. If not, see . // // See COPYRIGHT and LICENSE files for more details. //++ 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 index bea58a215763..4592b03138b9 100644 --- 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 @@ -20,8 +20,7 @@ // 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. +// along with this program. If not, see . // // See COPYRIGHT and LICENSE files for more details. //++