From 9f67ef3918655fe35ac9cf8ab160b7a43302d2fc Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 23 Sep 2026 18:40:21 +0300 Subject: [PATCH 01/10] Expose labels in the work package schema Labels are global and unbounded, so allowed values are linked to a project-scoped endpoint rather than embedded. The flag joins the schema cache dependencies so toggling it does not serve stale schemas. --- app/models/type/attribute_groups.rb | 1 + config/locales/en.yml | 1 + lib/api/v3/utilities/path_helper.rb | 4 ++ .../schema/work_package_schema_representer.rb | 15 +++++- .../work_package_schema_representer_spec.rb | 53 +++++++++++++++++++ 5 files changed, 73 insertions(+), 1 deletion(-) diff --git a/app/models/type/attribute_groups.rb b/app/models/type/attribute_groups.rb index c5e2ad449b69..fd2bb5acdd4d 100644 --- a/app/models/type/attribute_groups.rb +++ b/app/models/type/attribute_groups.rb @@ -51,6 +51,7 @@ module Type::AttributeGroups percentage_done: :estimates_and_progress, spent_time: :estimates_and_progress, priority: :details, + labels: :details, # `:excluded` is not a "real" group. It's meant to exclude built in fields from the form observed_in_versions: :excluded } diff --git a/config/locales/en.yml b/config/locales/en.yml index 9d5cf9d1c8c9..5c1eee2cb0b8 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -506,6 +506,7 @@ en: title: "Working days" true: "include non-working days" journal_internal: Internal Journal + labels: "Labels" notify: "Notify" # used in custom actions observed_in_versions: "Observed in versions" parent: "Parent" diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index 7ca50e7d3f36..291dae876fdb 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -633,6 +633,10 @@ def self.views_type(type) index :label show :label + def self.labels_by_project(project_id) + "#{project(project_id)}/labels" + end + def self.versions_available_projects "#{versions}/available_projects" end diff --git a/lib/api/v3/work_packages/schema/work_package_schema_representer.rb b/lib/api/v3/work_packages/schema/work_package_schema_representer.rb index 77001394c3dd..72e96b97877e 100644 --- a/lib/api/v3/work_packages/schema/work_package_schema_representer.rb +++ b/lib/api/v3/work_packages/schema/work_package_schema_representer.rb @@ -42,7 +42,8 @@ class WorkPackageSchemaRepresenter < ::API::Decorators::SchemaRepresenter dependencies: -> { all_permissions_granted_to_user_under_project + [Setting.work_package_done_ratio, - Setting::WorkPackageMultipleVersions.active?] + Setting::WorkPackageMultipleVersions.active?, + OpenProject::FeatureDecisions.work_package_labels_active?] } custom_field_injector type: :schema_representer @@ -287,6 +288,12 @@ def initialize(schema, self_link:, **context) required: false, href_callback: ->(*) { assignee_user_autocompleter } + schema_with_allowed_link :labels, + type: "[]Label", + required: false, + show_if: ->(*) { OpenProject::FeatureDecisions.work_package_labels_active? }, + href_callback: ->(*) { labels_autocompleter } + schema_with_allowed_collection :type, value_representer: Types::TypeRepresenter, link_factory: ->(type) { @@ -468,6 +475,12 @@ def all_permissions_granted_to_user_under_project .sort end + def labels_autocompleter + project_id = represented.work_package&.project_id + + api_v3_paths.labels_by_project(project_id) if project_id + end + def assignee_user_autocompleter work_package = represented.work_package diff --git a/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb b/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb index dc184e28d7f5..bd178317716a 100644 --- a/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb +++ b/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb @@ -1242,6 +1242,59 @@ end end + describe "labels" do + context "with the feature flag active", with_flag: :work_package_labels do + it_behaves_like "has basic schema properties" do + let(:path) { "labels" } + let(:type) { "[]Label" } + let(:name) { I18n.t("activerecord.attributes.work_package.labels") } + let(:required) { false } + let(:writable) { true } + let(:location) { "_links" } + end + + it_behaves_like "links to allowed values via collection link" do + let(:path) { "labels" } + let(:href) { api_v3_paths.labels_by_project(work_package.project_id) } + end + + context "when not embedded" do + let(:embedded) { false } + + it_behaves_like "does not link to allowed values" do + let(:path) { "labels" } + end + end + + context "when not having a project (yet)" do + before { work_package.project = nil } + + it_behaves_like "does not link to allowed values" do + let(:path) { "labels" } + end + end + + context "when lacking the edit_work_packages permission" do + let(:permissions) { [] } + + it_behaves_like "has basic schema properties" do + let(:path) { "labels" } + let(:type) { "[]Label" } + let(:name) { I18n.t("activerecord.attributes.work_package.labels") } + let(:required) { false } + let(:writable) { false } + let(:location) { "_links" } + end + end + end + + context "with the feature flag inactive" do + it "is not part of the schema" do + expect(generated).not_to have_json_path("labels") + end + end + end + describe "budget" do context "when user allowed to view_budgets" do let(:permissions) { %i[edit_work_packages view_budgets] } From aa938841c753781de7b4a2ee0b64ded58c8d2b9f Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 23 Sep 2026 20:03:34 +0300 Subject: [PATCH 02/10] Add labels listing by workspace ordered by relevance The work package labels dropdown needs labels already used in the current workspace first, then the most used ones, so the schema points at this endpoint instead of the global listing. --- app/models/label.rb | 19 ++- docs/api/apiv3/openapi-spec.yml | 2 + docs/api/apiv3/paths/workspace_labels.yml | 63 ++++++++++ lib/api/v3/labels/labels_by_workspace_api.rb | 54 +++++++++ lib/api/v3/utilities/path_helper.rb | 4 + .../schema/work_package_schema_representer.rb | 2 +- lib/api/v3/workspaces/nested_apis.rb | 1 + spec/lib/api/v3/utilities/path_helper_spec.rb | 12 ++ .../work_package_schema_representer_spec.rb | 2 +- spec/models/label_spec.rb | 31 +++++ .../api/v3/workspaces/label_resource_spec.rb | 113 ++++++++++++++++++ 11 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 docs/api/apiv3/paths/workspace_labels.yml create mode 100644 lib/api/v3/labels/labels_by_workspace_api.rb create mode 100644 spec/requests/api/v3/workspaces/label_resource_spec.rb diff --git a/app/models/label.rb b/app/models/label.rb index c2e59bff3ef9..b41d86ae23cc 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -32,8 +32,25 @@ class Label < ApplicationRecord belongs_to :author, class_name: "User" has_many :labelings, dependent: :delete_all + USAGE_COUNT_SQL = "(SELECT COUNT(*) FROM labelings WHERE labelings.label_id = labels.id)" + scope :with_usage_count, -> { - select("labels.*, (SELECT COUNT(*) FROM labelings WHERE labelings.label_id = labels.id) AS usage_count") + select("labels.*, #{USAGE_COUNT_SQL} AS usage_count") + } + + scope :ordered_by_relevance_for, ->(project) { + used_in_project = Labeling + .where(labelable_type: WorkPackage.name) + .where(Labeling.arel_table[:label_id].eq(arel_table[:id])) + .joins("INNER JOIN work_packages ON work_packages.id = labelings.labelable_id") + .where(work_packages: { project_id: project }) + .arel + .exists + + with_usage_count + .order(used_in_project.desc) + .order(Arel.sql("#{USAGE_COUNT_SQL} DESC")) + .order(arel_table[:name].lower.asc) } normalizes :name, with: -> { it.squish } diff --git a/docs/api/apiv3/openapi-spec.yml b/docs/api/apiv3/openapi-spec.yml index 51b0fd9aaaad..b6861011778c 100644 --- a/docs/api/apiv3/openapi-spec.yml +++ b/docs/api/apiv3/openapi-spec.yml @@ -653,6 +653,8 @@ paths: "$ref": "./paths/workspace_work_packages_form.yml" "/api/v3/workspaces/{id}/versions": "$ref": "./paths/workspace_versions.yml" + "/api/v3/workspaces/{id}/labels": + "$ref": "./paths/workspace_labels.yml" "/api/v3/workspaces/schema": "$ref": "./paths/workspaces_schema.yml" diff --git a/docs/api/apiv3/paths/workspace_labels.yml b/docs/api/apiv3/paths/workspace_labels.yml new file mode 100644 index 000000000000..4b68b9c81019 --- /dev/null +++ b/docs/api/apiv3/paths/workspace_labels.yml @@ -0,0 +1,63 @@ +# /api/v3/workspaces/{id}/labels +--- +get: + parameters: + - description: ID of the workspace whose labels will be listed + example: 1 + in: path + name: id + required: true + schema: + type: integer + - name: filters + description: |- + JSON specifying filter conditions. + Currently supported filters are: + + + name: filters labels by name, with the operators `~` (contains), `!~` (does not contain) and `**` (all) + example: '[{ "name": { "operator": "~", "values": ["bug"] } }]' + in: query + required: false + schema: + type: string + responses: + '200': + content: + application/hal+json: + examples: + 'simple label collection': + $ref: "../components/examples/label_collection.yml" + schema: + "$ref": "../components/schemas/label_collection_model.yml" + description: OK + headers: {} + '400': + $ref: "../components/responses/invalid_query.yml" + '404': + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + examples: + response: + value: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:NotFound + message: The specified workspace does not exist. + description: |- + Returned if the workspace does not exist or the client does not have sufficient permissions + to see it. + + **Required permission:** view work packages (on given workspace) + + *Note: A client without sufficient permissions shall not be able to test for the existence of a workspace. + That's why a 404 is returned here, even if a 403 might be more appropriate.* + headers: {} + tags: + - Labels + description: |- + Returns a paginated collection of all labels, ordered by relevance for the given workspace: + labels already used on work packages of the workspace come first, followed by the remaining labels. + Within each group, labels are ordered by how often they are used overall and then by name. + operationId: List_labels_by_workspace + summary: List labels by workspace diff --git a/lib/api/v3/labels/labels_by_workspace_api.rb b/lib/api/v3/labels/labels_by_workspace_api.rb new file mode 100644 index 000000000000..e37d1b4d92f0 --- /dev/null +++ b/lib/api/v3/labels/labels_by_workspace_api.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +#-- 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. +#++ + +module API + module V3 + module Labels + class LabelsByWorkspaceAPI < ::API::OpenProjectAPI + resources :labels do + after_validation do + raise API::Errors::NotFound unless OpenProject::FeatureDecisions.work_package_labels_active? + + authorize_in_project(:view_work_packages, project: @project) + + @labels = Label.ordered_by_relevance_for(@project) + end + + get do + ::API::V3::Utilities::ParamsToQuery.collection_response(@labels, + current_user, + params.except("id"), + self_link: api_v3_paths.labels_by_workspace(@project.id)) + end + end + end + end + end +end diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index 291dae876fdb..03b2b5102974 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -637,6 +637,10 @@ def self.labels_by_project(project_id) "#{project(project_id)}/labels" end + def self.labels_by_workspace(workspace_id) + "#{workspace(workspace_id)}/labels" + end + def self.versions_available_projects "#{versions}/available_projects" end diff --git a/lib/api/v3/work_packages/schema/work_package_schema_representer.rb b/lib/api/v3/work_packages/schema/work_package_schema_representer.rb index 72e96b97877e..0476dfab4539 100644 --- a/lib/api/v3/work_packages/schema/work_package_schema_representer.rb +++ b/lib/api/v3/work_packages/schema/work_package_schema_representer.rb @@ -478,7 +478,7 @@ def all_permissions_granted_to_user_under_project def labels_autocompleter project_id = represented.work_package&.project_id - api_v3_paths.labels_by_project(project_id) if project_id + api_v3_paths.labels_by_workspace(project_id) if project_id end def assignee_user_autocompleter diff --git a/lib/api/v3/workspaces/nested_apis.rb b/lib/api/v3/workspaces/nested_apis.rb index 4f9c27d99c9f..60f7af0c765e 100644 --- a/lib/api/v3/workspaces/nested_apis.rb +++ b/lib/api/v3/workspaces/nested_apis.rb @@ -37,6 +37,7 @@ class NestedApis < ::API::OpenProjectAPI mount API::V3::WorkPackages::WorkPackagesByWorkspaceAPI mount API::V3::Categories::CategoriesByWorkspaceAPI mount API::V3::Versions::VersionsByProjectAPI + mount API::V3::Labels::LabelsByWorkspaceAPI mount API::V3::Queries::QueriesByWorkspaceAPI mount API::V3::Favorites::FavoriteActionsAPI, with: { favorite_object_getter: ->(*) { @project } } end diff --git a/spec/lib/api/v3/utilities/path_helper_spec.rb b/spec/lib/api/v3/utilities/path_helper_spec.rb index 24e704e3b10a..1522161104e7 100644 --- a/spec/lib/api/v3/utilities/path_helper_spec.rb +++ b/spec/lib/api/v3/utilities/path_helper_spec.rb @@ -491,6 +491,18 @@ it_behaves_like "api v3 path", "/versions/available_projects" end + describe "#labels_by_project" do + subject { helper.labels_by_project 42 } + + it_behaves_like "api v3 path", "/projects/42/labels" + end + + describe "#labels_by_workspace" do + subject { helper.labels_by_workspace 42 } + + it_behaves_like "api v3 path", "/workspaces/42/labels" + end + describe "#versions_by_project" do subject { helper.versions_by_project 42 } diff --git a/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb b/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb index bd178317716a..f82b8db7bb40 100644 --- a/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb +++ b/spec/lib/api/v3/work_packages/schema/work_package_schema_representer_spec.rb @@ -1255,7 +1255,7 @@ it_behaves_like "links to allowed values via collection link" do let(:path) { "labels" } - let(:href) { api_v3_paths.labels_by_project(work_package.project_id) } + let(:href) { api_v3_paths.labels_by_workspace(work_package.project_id) } end context "when not embedded" do diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb index 87db8b204fcd..55ab76708cfa 100644 --- a/spec/models/label_spec.rb +++ b/spec/models/label_spec.rb @@ -74,6 +74,37 @@ end end + describe ".ordered_by_relevance_for" do + let(:project) { create(:project) } + let(:other_project) { create(:project) } + + let!(:popular_elsewhere) { create(:label, name: "popular elsewhere") } + let!(:local_rare) { create(:label, name: "local rare") } + let!(:local_common) { create(:label, name: "local common") } + let!(:unused_a) { create(:label, name: "Unused A") } + let!(:unused_b) { create(:label, name: "unused b") } + + before do + label_work_packages(popular_elsewhere, other_project, count: 3) + label_work_packages(local_rare, project, count: 1) + label_work_packages(local_common, project, count: 2) + end + + def label_work_packages(label, project, count:) + create_list(:work_package, count, project:).each { create(:labeling, label:, labelable: it) } + end + + it "lists labels used in the project first, then by usage, then case-insensitively by name" do + expect(described_class.ordered_by_relevance_for(project).to_a) + .to eq([local_common, local_rare, popular_elsewhere, unused_a, unused_b]) + end + + it "keeps a scalar total when paginated" do + expect(described_class.ordered_by_relevance_for(project).paginate(page: 1, per_page: 2).total_entries) + .to eq(5) + end + end + describe ".page_of" do let!(:alpha) { create(:label, name: "Alpha") } let!(:bravo) { create(:label, name: "bravo") } diff --git a/spec/requests/api/v3/workspaces/label_resource_spec.rb b/spec/requests/api/v3/workspaces/label_resource_spec.rb new file mode 100644 index 000000000000..492048742c71 --- /dev/null +++ b/spec/requests/api/v3/workspaces/label_resource_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +# -- 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. +# ++ + +require "spec_helper" +require "rack/test" + +RSpec.describe "GET workspaces/:id/labels", with_flag: :work_package_labels do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + shared_let(:project) { create(:project, public: false) } + shared_let(:other_project) { create(:project) } + shared_let(:permitted_user) { create(:user, member_with_permissions: { project => [:view_work_packages] }) } + shared_let(:unpermitted_user) { create(:user, member_with_permissions: { project => [] }) } + + shared_let(:popular_elsewhere) { create(:label, name: "popular elsewhere") } + shared_let(:local_label) { create(:label, name: "local") } + shared_let(:unused_label) { create(:label, name: "unused") } + + before_all do + create_list(:work_package, 2, project: other_project).each { create(:labeling, label: popular_elsewhere, labelable: it) } + create(:labeling, label: local_label, labelable: create(:work_package, project:)) + end + + subject(:response) { last_response } + + shared_context "with labels by workspace" do + context "for a user with permission to view work packages" do + current_user { permitted_user } + + before do + get get_path + end + + it_behaves_like "API V3 collection response", 3, 3, "Label" do + let(:elements) { [local_label, popular_elsewhere, unused_label] } + end + end + + context "with a name filter" do + current_user { permitted_user } + + before do + filter = [{ name: { operator: "~", values: ["popular"] } }] + + get "#{get_path}?filters=#{CGI.escape(filter.to_json)}" + end + + it_behaves_like "API V3 collection response", 1, 1, "Label" do + let(:elements) { [popular_elsewhere] } + end + end + + context "for a user without permission to view work packages" do + current_user { unpermitted_user } + + before do + get get_path + end + + it_behaves_like "unauthorized access" + end + + context "with the feature flag inactive", with_flag: { work_package_labels: false } do + current_user { permitted_user } + + before do + get get_path + end + + it_behaves_like "not found" + end + end + + context "for workspaces/:id/labels" do + let(:get_path) { api_v3_paths.labels_by_workspace project.id } + + include_context "with labels by workspace" + end + + context "for projects/:id/labels" do + let(:get_path) { api_v3_paths.labels_by_project project.id } + + include_context "with labels by workspace" + end +end From 3bb2a19665027c04e8d6afe4f4343600404ae041 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 23 Sep 2026 20:40:01 +0300 Subject: [PATCH 03/10] Add label creation endpoint with case-insensitive dedupe Creating a label from the work package dropdown must hand back the existing label when one with the same name in another casing exists, including when it appears concurrently between lookup and insert. --- app/models/label.rb | 4 + app/services/labels/find_or_create_service.rb | 61 ++++++++ .../components/schemas/label_write_model.yml | 13 ++ docs/api/apiv3/paths/labels.yml | 65 +++++++++ .../v3/labels/label_payload_representer.rb | 41 ++++++ lib/api/v3/labels/labels_api.rb | 19 +++ spec/requests/api/v3/label_resource_spec.rb | 47 +++++++ .../labels/find_or_create_service_spec.rb | 131 ++++++++++++++++++ 8 files changed, 381 insertions(+) create mode 100644 app/services/labels/find_or_create_service.rb create mode 100644 docs/api/apiv3/components/schemas/label_write_model.yml create mode 100644 lib/api/v3/labels/label_payload_representer.rb create mode 100644 spec/services/labels/find_or_create_service_spec.rb diff --git a/app/models/label.rb b/app/models/label.rb index b41d86ae23cc..e46dc86db169 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -53,6 +53,10 @@ class Label < ApplicationRecord .order(arel_table[:name].lower.asc) } + scope :named, ->(name) { + where("LOWER(labels.name) = LOWER(?)", normalize_value_for(:name, name)) + } + normalizes :name, with: -> { it.squish } validates :name, diff --git a/app/services/labels/find_or_create_service.rb b/app/services/labels/find_or_create_service.rb new file mode 100644 index 000000000000..210b53c6cb82 --- /dev/null +++ b/app/services/labels/find_or_create_service.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +#-- 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. +#++ + +module Labels + class FindOrCreateService + attr_reader :user + + def initialize(user:) + @user = user + end + + def call(name:) + existing = Label.named(name).first + return ServiceResult.success(result: existing) if existing + + create(name) + end + + private + + def create(name) + call = Label.transaction(requires_new: true) { CreateService.new(user:).call(name:) } + return call if call.success? || call.errors.symbols_for(:name).exclude?(:taken) + + existing(name) + rescue ActiveRecord::RecordNotUnique + existing(name) + end + + def existing(name) + ServiceResult.success(result: Label.named(name).first!) + end + end +end diff --git a/docs/api/apiv3/components/schemas/label_write_model.yml b/docs/api/apiv3/components/schemas/label_write_model.yml new file mode 100644 index 000000000000..4fbc9b3364a6 --- /dev/null +++ b/docs/api/apiv3/components/schemas/label_write_model.yml @@ -0,0 +1,13 @@ +# Schema: LabelWriteModel +--- +type: object +required: + - name +properties: + name: + type: string + description: Label name. Matched case-insensitively against existing labels. + minLength: 1 + maxLength: 255 +example: + name: Bug diff --git a/docs/api/apiv3/paths/labels.yml b/docs/api/apiv3/paths/labels.yml index 1490a699da5a..445d904d7cee 100644 --- a/docs/api/apiv3/paths/labels.yml +++ b/docs/api/apiv3/paths/labels.yml @@ -47,3 +47,68 @@ get: required: false schema: type: string +post: + summary: Create Label + operationId: create_label + tags: + - Labels + description: |- + Creates a label with the given name, or returns the existing label whose name matches case-insensitively. + Leading, trailing and repeated whitespace in the name is collapsed before matching. + requestBody: + content: + application/json: + schema: + $ref: "../components/schemas/label_write_model.yml" + responses: + '200': + description: OK. A label with that name already exists and is returned unchanged. + content: + application/hal+json: + schema: + $ref: "../components/schemas/label_model.yml" + '201': + description: Created + content: + application/hal+json: + schema: + $ref: "../components/schemas/label_model.yml" + '400': + $ref: "../components/responses/invalid_request_body.yml" + '403': + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + examples: + response: + value: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:MissingPermission + message: You are not authorized to access this resource. + description: |- + Returned if the client does not have sufficient permissions. + + **Required permission:** edit work packages (on any project) + '406': + $ref: "../components/responses/missing_content_type.yml" + '415': + $ref: "../components/responses/unsupported_media_type.yml" + '422': + description: |- + Returned if: + + * a constraint for a property was violated (`PropertyConstraintViolation`) + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + examples: + response: + value: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:PropertyConstraintViolation + message: Name cannot be empty. + _embedded: + details: + attribute: name diff --git a/lib/api/v3/labels/label_payload_representer.rb b/lib/api/v3/labels/label_payload_representer.rb new file mode 100644 index 000000000000..5199a2e5c2af --- /dev/null +++ b/lib/api/v3/labels/label_payload_representer.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +#-- 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. +#++ + +module API + module V3 + module Labels + class LabelPayloadRepresenter < LabelRepresenter + include ::API::Utilities::PayloadRepresenter + + cached_representer disabled: true + end + end + end +end diff --git a/lib/api/v3/labels/labels_api.rb b/lib/api/v3/labels/labels_api.rb index 6948e5f40d7f..b9a1b3abe121 100644 --- a/lib/api/v3/labels/labels_api.rb +++ b/lib/api/v3/labels/labels_api.rb @@ -29,6 +29,7 @@ #++ require "api/v3/labels/label_collection_representer" +require "api/v3/labels/label_payload_representer" require "api/v3/labels/label_representer" module API @@ -46,6 +47,24 @@ class LabelsAPI < ::API::OpenProjectAPI .new(model: Label) .mount + post do + authorize_in_any_project(:edit_work_packages) + + attributes = ::API::V3::ParseResourceParamsService + .new(current_user, model: Label) + .call(request_body) + .result + + call = ::Labels::FindOrCreateService.new(user: current_user).call(name: attributes[:name]) + + if call.success? + status call.result.previously_new_record? ? :created : :ok + LabelRepresenter.create(call.result, current_user:, embed_links: true) + else + fail ::API::Errors::ErrorBase.create_and_merge_errors(call.errors) + end + end + route_param :id, type: Integer, desc: "Label ID" do after_validation do @label = Label.find(params[:id]) diff --git a/spec/requests/api/v3/label_resource_spec.rb b/spec/requests/api/v3/label_resource_spec.rb index 4b9b395fb083..dff78ffe41ff 100644 --- a/spec/requests/api/v3/label_resource_spec.rb +++ b/spec/requests/api/v3/label_resource_spec.rb @@ -121,6 +121,53 @@ it_behaves_like "forbidden response based on login_required" end end + + describe "#post" do + let(:role) { create(:project_role, permissions: %i[view_work_packages edit_work_packages]) } + let(:name) { "Needs review" } + + subject(:response) { last_response } + + before do + allow(User).to receive(:current).and_return current_user + + post api_v3_paths.labels, { name: }.to_json, "CONTENT_TYPE" => "application/json" + end + + it "creates the label" do + expect(response).to have_http_status(:created) + expect(response.body).to be_json_eql("Needs review".to_json).at_path("name") + expect(Label.named("Needs review")).to exist + end + + context "when a label with the same name in different casing exists" do + let(:name) { "URGENT" } + + it "responds with the existing label instead of creating one" do + expect(response).to have_http_status(:ok) + expect(response.body).to be_json_eql(urgent.id.to_json).at_path("id") + expect(Label.count).to eq(3) + end + end + + context "with a blank name" do + let(:name) { "" } + + it_behaves_like "constraint violation" do + let(:message) { "Name cannot be empty." } + end + end + + context "without edit_work_packages permission" do + let(:role) { create(:project_role, permissions: [:view_work_packages]) } + + it_behaves_like "unauthorized access" + end + + context "with the feature flag inactive", with_flag: { work_package_labels: false } do + it_behaves_like "not found" + end + end end describe "labels/:id" do diff --git a/spec/services/labels/find_or_create_service_spec.rb b/spec/services/labels/find_or_create_service_spec.rb new file mode 100644 index 000000000000..434c2b4e550b --- /dev/null +++ b/spec/services/labels/find_or_create_service_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +#-- 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. +#++ + +require "spec_helper" + +RSpec.describe Labels::FindOrCreateService do + shared_let(:user) { create(:admin) } + + subject(:call) { described_class.new(user:).call(name:) } + + context "when no label with that name exists" do + let(:name) { "Bug" } + + it "creates the label" do + expect(call).to be_success + expect(call.result).to be_persisted + expect(call.result.name).to eq("Bug") + expect(call.result).to be_previously_new_record + end + end + + context "when a label with the same name in different casing exists" do + shared_let(:existing) { create(:label, name: "bug") } + + let(:name) { " BUG " } + + it "returns the existing label without creating another one" do + expect { call }.not_to change(Label, :count) + + expect(call).to be_success + expect(call.result).to eq(existing) + expect(call.result).not_to be_previously_new_record + end + + context "with a name Ruby and Postgres lowercase differently" do + shared_let(:existing) { create(:label, name: "İstanbul") } + + let(:name) { "İstanbul" } + + it "still returns the existing label" do + expect(call).to be_success + expect(call.result).to eq(existing) + end + end + end + + context "when another request creates the label between lookup and validation" do + let(:name) { "Bug" } + + before do + allow(Labels::CreateService).to receive(:new).and_wrap_original do |original, **args| + create(:label, name: "bug") + original.call(**args) + end + end + + it "returns the label created by the other request" do + expect(call).to be_success + expect(call.result.name).to eq("bug") + expect(Label.count).to eq(1) + end + end + + context "when another request creates the label between validation and insert" do + let(:name) { "Bug" } + + before do + create(:label, name: "bug") + + lookups = 0 + allow(Label).to receive(:named).and_wrap_original do |original, *args| + lookups += 1 + lookups == 1 ? Label.none : original.call(*args) + end + allow_any_instance_of(Label).to receive(:valid?).and_return(true) # rubocop:disable RSpec/AnyInstance + end + + it "returns the label created by the other request" do + expect(call).to be_success + expect(call.result.name).to eq("bug") + expect(Label.count).to eq(1) + end + end + + context "when the user lacks the permission to edit work packages" do + shared_let(:user) { create(:user) } + + let(:name) { "Bug" } + + it "fails" do + expect(call).to be_failure + expect(call.errors.symbols_for(:base)).to include(:error_unauthorized) + end + end + + context "with a blank name" do + let(:name) { " " } + + it "fails" do + expect(call).to be_failure + expect(call.errors.symbols_for(:name)).to include(:blank) + end + end +end From 2bfe08d1cc0d7c14ffecf9bbdd739dbffa50f2f7 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 23 Sep 2026 21:31:55 +0300 Subject: [PATCH 04/10] Report concurrent duplicate labels as a validation error A duplicate inserted between the uniqueness validation and the INSERT raised through the admin form as a 500. The create service now turns the unique index violation into the same "taken" error, so every caller, including the find-or-create path, handles the race the same way. --- app/services/labels/create_service.rb | 11 +++++++++++ app/services/labels/find_or_create_service.rb | 4 +--- spec/services/labels/create_service_spec.rb | 11 +++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/app/services/labels/create_service.rb b/app/services/labels/create_service.rb index 61b4bee4b80d..7a7753ac5a07 100644 --- a/app/services/labels/create_service.rb +++ b/app/services/labels/create_service.rb @@ -30,5 +30,16 @@ module Labels class CreateService < ::BaseServices::Create + protected + + # The savepoint keeps a unique index violation from aborting a transaction the caller may already have open. + def persist(service_result) + Label.transaction(requires_new: true) { super } + rescue ActiveRecord::RecordNotUnique + service_result.result.errors.add(:name, :taken) + service_result.errors = service_result.result.errors + service_result.success = false + service_result + end end end diff --git a/app/services/labels/find_or_create_service.rb b/app/services/labels/find_or_create_service.rb index 210b53c6cb82..e4bc5c558dcf 100644 --- a/app/services/labels/find_or_create_service.rb +++ b/app/services/labels/find_or_create_service.rb @@ -46,11 +46,9 @@ def call(name:) private def create(name) - call = Label.transaction(requires_new: true) { CreateService.new(user:).call(name:) } + call = CreateService.new(user:).call(name:) return call if call.success? || call.errors.symbols_for(:name).exclude?(:taken) - existing(name) - rescue ActiveRecord::RecordNotUnique existing(name) end diff --git a/spec/services/labels/create_service_spec.rb b/spec/services/labels/create_service_spec.rb index 4de08743e4fd..6206771cfe50 100644 --- a/spec/services/labels/create_service_spec.rb +++ b/spec/services/labels/create_service_spec.rb @@ -67,6 +67,17 @@ expect(result.errors.symbols_for(:name)).to include(:taken) end + it "fails when the name is taken by an insert racing past validation" do + create(:label, name: "bug") + allow_any_instance_of(Label).to receive(:valid?).and_return(true) # rubocop:disable RSpec/AnyInstance + + result = described_class.new(user:).call(name: "Bug") + + expect(result).to be_failure + expect(result.errors.symbols_for(:name)).to include(:taken) + expect(Label.count).to eq(1) + end + context "with a user lacking edit_work_packages in any project" do let(:user) { create(:user) } From 18fbf02d4fbf8459a4b9af34a21760f563c6926b Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 23 Sep 2026 21:56:43 +0300 Subject: [PATCH 05/10] Document label name as writable Labels can now be created through the API. --- docs/api/apiv3/tags/labels.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/apiv3/tags/labels.yml b/docs/api/apiv3/tags/labels.yml index 6703330abeb0..1508b4c956c4 100644 --- a/docs/api/apiv3/tags/labels.yml +++ b/docs/api/apiv3/tags/labels.yml @@ -13,7 +13,7 @@ description: |- | Property | Description | Type | Constraints | Supported operations | | :---------: | ------------------------------------------- | ---------- | ----------- | -------------------- | | id | Label id | Integer | x > 0 | READ | - | name | Label name | String | not empty | READ | + | name | Label name | String | not empty | READ / WRITE | | createdAt | The time the label was created | DateTime | | READ | | updatedAt | The time the label was last updated | DateTime | | READ | name: Labels From 2bbd6ca0a70a296a84c73489001d886dd20ee0db Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 23 Sep 2026 22:22:38 +0300 Subject: [PATCH 06/10] Include labels in the work package cache checksum Assigning labels does not touch the work package row, so without this the cached JSON representation kept serving the previous labels. --- .../work_packages/eager_loading/checksum.rb | 16 ++++-- .../cache_checksum_integration_spec.rb | 56 +++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/lib/api/v3/work_packages/eager_loading/checksum.rb b/lib/api/v3/work_packages/eager_loading/checksum.rb index e9bbf62a7b2d..f68da2beced2 100644 --- a/lib/api/v3/work_packages/eager_loading/checksum.rb +++ b/lib/api/v3/work_packages/eager_loading/checksum.rb @@ -56,10 +56,10 @@ def fetch_checksums_for(work_packages) protected - # Versions are a has_many, which would multiply rows in the - # left_joins/pluck above, so they enter as an aggregated subquery. - # A version can attach under more than one kind, so the kind is part - # of the value and of the order. + # Versions and labels are both has_many, which would multiply rows + # in the left_joins/pluck above, so they enter as aggregated + # subqueries instead. A version can attach under more than one + # kind, so the kind is part of the value and of the order. VERSIONS_CHECKSUM_SQL = <<~SQL.squish (SELECT COALESCE(STRING_AGG(CONCAT(wpv.kind, v.id, v.updated_at), ',' ORDER BY wpv.kind, v.id), '') FROM work_package_versions wpv @@ -67,6 +67,13 @@ def fetch_checksums_for(work_packages) WHERE wpv.work_package_id = work_packages.id) SQL + LABELS_CHECKSUM_SQL = <<~SQL.squish + (SELECT COALESCE(STRING_AGG(CONCAT(l.id, l.updated_at), ',' ORDER BY l.id), '') + FROM labelings lg + INNER JOIN labels l ON l.id = lg.label_id + WHERE lg.labelable_type = 'WorkPackage' AND lg.labelable_id = work_packages.id) + SQL + def md5_concat md5_parts = checksum_associations.flat_map do |association_name| table_name = md5_checksum_table_name(association_name) @@ -74,6 +81,7 @@ def md5_concat %W[#{table_name}.id #{table_name}.updated_at] end md5_parts << VERSIONS_CHECKSUM_SQL + md5_parts << LABELS_CHECKSUM_SQL <<-SQL MD5(CONCAT(#{md5_parts.join(', ')})) diff --git a/spec/lib/api/v3/work_packages/eager_loading/cache_checksum_integration_spec.rb b/spec/lib/api/v3/work_packages/eager_loading/cache_checksum_integration_spec.rb index ed577e05ed09..efc24d508c38 100644 --- a/spec/lib/api/v3/work_packages/eager_loading/cache_checksum_integration_spec.rb +++ b/spec/lib/api/v3/work_packages/eager_loading/cache_checksum_integration_spec.rb @@ -231,5 +231,61 @@ expect(new_checksum) .not_to eql orig_checksum end + + it "produces a different checksum when a label is added to the work package" do + label = create(:label) + + create(:labeling, labelable: work_package, label:) + + expect(new_checksum) + .not_to eql orig_checksum + end + + it "produces a different checksum when a label is removed from the work package" do + label = create(:label) + labeling = create(:labeling, labelable: work_package, label:) + + previous_checksum = EagerLoadingMockWrapper + .wrap(described_class, [work_package]) + .first + .cache_checksum + + labeling.destroy! + + expect(new_checksum) + .not_to eql previous_checksum + end + + it "produces a different checksum when an assigned label is renamed" do + label = create(:label) + create(:labeling, labelable: work_package, label:) + + previous_checksum = EagerLoadingMockWrapper + .wrap(described_class, [work_package]) + .first + .cache_checksum + + label.update_attribute(:name, "Renamed label #{label.id}") + + expect(new_checksum) + .not_to eql previous_checksum + end + + it "produces the same checksum when a label not assigned to the work package changes" do + assigned_label = create(:label) + create(:labeling, labelable: work_package, label: assigned_label) + unassigned_label = create(:label) + create(:labeling, labelable: create(:work_package, project:), label: unassigned_label) + + previous_checksum = EagerLoadingMockWrapper + .wrap(described_class, [work_package]) + .first + .cache_checksum + + unassigned_label.update_attribute(:name, "Renamed label #{unassigned_label.id}") + + expect(new_checksum) + .to eql previous_checksum + end end end From 598415a9bda6ed96484505c79b4e8300df677724 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 23 Sep 2026 22:32:20 +0300 Subject: [PATCH 07/10] Derive the labelable type in the checksum from the model --- lib/api/v3/work_packages/eager_loading/checksum.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/api/v3/work_packages/eager_loading/checksum.rb b/lib/api/v3/work_packages/eager_loading/checksum.rb index f68da2beced2..e3d49f51697b 100644 --- a/lib/api/v3/work_packages/eager_loading/checksum.rb +++ b/lib/api/v3/work_packages/eager_loading/checksum.rb @@ -56,10 +56,10 @@ def fetch_checksums_for(work_packages) protected - # Versions and labels are both has_many, which would multiply rows - # in the left_joins/pluck above, so they enter as aggregated - # subqueries instead. A version can attach under more than one - # kind, so the kind is part of the value and of the order. + # Versions and labels are has_many and enter as aggregated subqueries; + # joining them in the pluck above would multiply the rows. A version + # can attach under more than one kind, so the kind is part of the + # value and of the order. VERSIONS_CHECKSUM_SQL = <<~SQL.squish (SELECT COALESCE(STRING_AGG(CONCAT(wpv.kind, v.id, v.updated_at), ',' ORDER BY wpv.kind, v.id), '') FROM work_package_versions wpv @@ -71,7 +71,7 @@ def fetch_checksums_for(work_packages) (SELECT COALESCE(STRING_AGG(CONCAT(l.id, l.updated_at), ',' ORDER BY l.id), '') FROM labelings lg INNER JOIN labels l ON l.id = lg.label_id - WHERE lg.labelable_type = 'WorkPackage' AND lg.labelable_id = work_packages.id) + WHERE lg.labelable_type = '#{WorkPackage.polymorphic_name}' AND lg.labelable_id = work_packages.id) SQL def md5_concat From f09e82f682b027e99af43dadbfe37eff43034104 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 23 Sep 2026 22:39:10 +0300 Subject: [PATCH 08/10] Show and edit labels on the work package view Labels render like the other multi-value attributes and are edited in a multi-select that searches on the server so the workspace-relevance ordering is kept. A dedicated labels autocompleter holds the fetching so other surfaces can reuse it. --- .../labels-autocompleter.component.ts | 90 +++++++++++++++++++ .../openproject-autocompleter.module.ts | 4 + .../display/display-field.initializer.ts | 2 +- .../fields/edit/edit-field.initializer.ts | 4 + .../labels-edit-field.component.html | 29 ++++++ .../labels-edit-field.component.ts | 69 ++++++++++++++ .../fields/openproject-fields.module.ts | 2 + .../inplace_editor/labels_editor_spec.rb | 69 ++++++++++++++ spec/support/edit_fields/edit_field.rb | 10 +++ 9 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component.ts create mode 100644 frontend/src/app/shared/components/fields/edit/field-types/labels-edit-field.component.html create mode 100644 frontend/src/app/shared/components/fields/edit/field-types/labels-edit-field.component.ts create mode 100644 spec/features/work_packages/details/inplace_editor/labels_editor_spec.rb diff --git a/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component.ts b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component.ts new file mode 100644 index 000000000000..d16cdcc1292a --- /dev/null +++ b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component.ts @@ -0,0 +1,90 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { keyBy } from 'lodash-es'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { + OpAutocompleterComponent, +} from 'core-app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component'; +import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder'; +import { addFiltersToPath } from 'core-app/core/apiv3/helpers/add-filters-to-path'; +import { IHALCollection } from 'core-app/core/apiv3/types/hal-collection.type'; +import { compareByAttribute } from 'core-app/shared/helpers/angular/tracking-functions'; + +export const labelsAutocompleterSelector = 'op-labels-autocompleter'; + +export interface ILabelAutocompleteItem { + id:string|number; + name:string; + href:string|null; +} + +interface IApiLabel { + id:string|number; + name:string; + _links:{ self:{ href:string|null } }; +} + +@Component({ + templateUrl: '../op-autocompleter/op-autocompleter.component.html', + selector: labelsAutocompleterSelector, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, +}) +export class LabelsAutocompleterComponent extends OpAutocompleterComponent { + getOptionsFn = this.getLabels.bind(this); + + public getLabels(searchTerm?:string):Observable { + const filterObject = keyBy(this.filters, 'name'); + const filters = ApiV3FilterBuilder.fromFilterObject(filterObject); + if (searchTerm?.length) { + filters.add('name', '~', [searchTerm]); + } + + const filteredURL = addFiltersToPath(this.url, filters); + filteredURL.searchParams.set('pageSize', '-1'); + filteredURL.searchParams.set('select', 'elements/id,elements/name,elements/self,total,count,pageSize'); + + return this + .http + .get>(filteredURL.toString()) + .pipe( + map((res) => res._embedded.elements.map((label) => ({ id: label.id, name: label.name, href: label._links.self.href }))), + ); + } + + protected defaultCompareWithFunction():(a:unknown, b:unknown) => boolean { + return compareByAttribute('href', 'name'); + } + + protected defaultTrackByFunction():(item:{ href:unknown, name:unknown }) => unknown { + return (item) => item.href ?? item.name; + } +} diff --git a/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts b/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts index cac7d5f3e9fa..6c9a1e8348d4 100644 --- a/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts +++ b/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts @@ -48,6 +48,9 @@ import { import { UserAutocompleterComponent, } from 'core-app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component'; +import { + LabelsAutocompleterComponent, +} from 'core-app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component'; import { MeetingAutocompleterComponent, } from 'core-app/shared/components/autocompleter/meeting-autocompleter/meeting-autocompleter.component'; @@ -109,6 +112,7 @@ export const OPENPROJECT_AUTOCOMPLETE_COMPONENTS = [ DraggableAutocompleteComponent, UserAutocompleterComponent, UserAutocompleterTemplateComponent, + LabelsAutocompleterComponent, ResourceAllocationAutocompleterComponent, ResourceAllocationAutocompleterTemplateComponent, MeetingAutocompleterTemplateComponent, diff --git a/frontend/src/app/shared/components/fields/display/display-field.initializer.ts b/frontend/src/app/shared/components/fields/display/display-field.initializer.ts index fe2831455430..76abdfb13ebd 100644 --- a/frontend/src/app/shared/components/fields/display/display-field.initializer.ts +++ b/frontend/src/app/shared/components/fields/display/display-field.initializer.ts @@ -114,7 +114,7 @@ export function initializeCoreDisplayFields(displayFieldService:DisplayFieldServ 'Workspace']) .addFieldType(ProjectPhaseDisplayField, 'projectPhase', ['ProjectPhase']) .addFieldType(ResourcesDisplayField, 'resources', ['[]CustomOption', '[]CustomField::Hierarchy::Item']) - .addFieldType(ResourcesDisplayField, 'resources', ['[]Version']) + .addFieldType(ResourcesDisplayField, 'resources', ['[]Version', '[]Label']) .addFieldType(MultipleUserFieldModule, 'users', ['[]User']) .addFieldType(FormattableDisplayField, 'formattable', ['Formattable']) .addFieldType(DaysDurationDisplayField, 'duration', ['duration']) diff --git a/frontend/src/app/shared/components/fields/edit/edit-field.initializer.ts b/frontend/src/app/shared/components/fields/edit/edit-field.initializer.ts index 01967f873396..bc09de16a5e7 100644 --- a/frontend/src/app/shared/components/fields/edit/edit-field.initializer.ts +++ b/frontend/src/app/shared/components/fields/edit/edit-field.initializer.ts @@ -84,6 +84,9 @@ import { import { VersionsEditFieldComponent, } from 'core-app/shared/components/fields/edit/field-types/versions-edit-field.component'; +import { + LabelsEditFieldComponent, +} from 'core-app/shared/components/fields/edit/field-types/labels-edit-field.component'; import { ProjectPhaseAutocompleterComponent } from '../../autocompleter/project-phase-autocompleter/project-phase-autocompleter.component'; export function initializeCoreEditFields(editFieldService:EditFieldService, selectAutocompleterRegisterService:SelectAutocompleterRegisterService) { @@ -95,6 +98,7 @@ export function initializeCoreEditFields(editFieldService:EditFieldService, sele .addFieldType(ProgressPopoverEditFieldComponent, 'progress', ['Progress']) .addFieldType(ProjectEditFieldComponent, 'project', ['Project']) .addFieldType(UserEditFieldComponent, 'user', ['User']) + .addFieldType(LabelsEditFieldComponent, 'labels', ['[]Label']) .addFieldType(SelectEditFieldComponent, 'select', [ 'Priority', 'ProjectPhase', diff --git a/frontend/src/app/shared/components/fields/edit/field-types/labels-edit-field.component.html b/frontend/src/app/shared/components/fields/edit/field-types/labels-edit-field.component.html new file mode 100644 index 000000000000..69d7a228f633 --- /dev/null +++ b/frontend/src/app/shared/components/fields/edit/field-types/labels-edit-field.component.html @@ -0,0 +1,29 @@ +@if (url) { + +} + +@if (!handler.inEditMode) { + +} diff --git a/frontend/src/app/shared/components/fields/edit/field-types/labels-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/labels-edit-field.component.ts new file mode 100644 index 000000000000..bea58a215763 --- /dev/null +++ b/frontend/src/app/shared/components/fields/edit/field-types/labels-edit-field.component.ts @@ -0,0 +1,69 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { + EditFieldComponent, +} from 'core-app/shared/components/fields/edit/edit-field.component'; +import { HalResourceService } from 'core-app/features/hal/services/hal-resource.service'; +import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; +import { CallableHalLink } from 'core-app/features/hal/hal-link/hal-link'; +import { + ILabelAutocompleteItem, +} from 'core-app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component'; + +@Component({ + templateUrl: './labels-edit-field.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, +}) +export class LabelsEditFieldComponent extends EditFieldComponent { + readonly halResourceService = inject(HalResourceService); + + isNew = isNewResource(this.resource as { id:string|null }); + + url:string; + + public text = { + save: this.I18n.t('js.inplace.button_save', { attribute: this.schema.name }), + cancel: this.I18n.t('js.inplace.button_cancel', { attribute: this.schema.name }), + }; + + initialize():void { + const link = this.schema.allowedValues as CallableHalLink|undefined; + if (link) { + this.url = link.$link.href!; + } + } + + public onModelChange(labels?:ILabelAutocompleteItem[]):void { + this.value = (labels ?? []).map( + ({ id, name, href }) => this.halResourceService.createHalResourceOfType('Label', { id, name, href }), + ); + } +} diff --git a/frontend/src/app/shared/components/fields/openproject-fields.module.ts b/frontend/src/app/shared/components/fields/openproject-fields.module.ts index e80823ffd576..f08445a387d0 100644 --- a/frontend/src/app/shared/components/fields/openproject-fields.module.ts +++ b/frontend/src/app/shared/components/fields/openproject-fields.module.ts @@ -65,6 +65,7 @@ import { ProgressPopoverEditFieldComponent } from 'core-app/shared/components/fi import { OpExclusionInfoComponent } from 'core-app/shared/components/fields/display/info/op-exclusion-info.component'; import { UserEditFieldComponent } from './edit/field-types/user-edit-field.component'; import { VersionsEditFieldComponent } from 'core-app/shared/components/fields/edit/field-types/versions-edit-field.component'; +import { LabelsEditFieldComponent } from 'core-app/shared/components/fields/edit/field-types/labels-edit-field.component'; import { DaysDurationEditFieldComponent } from 'core-app/shared/components/fields/edit/field-types/days-duration-edit-field.component'; import { CombinedDateEditFieldComponent } from './edit/field-types/combined-date-edit-field.component'; import { NgSelectModule } from '@ng-select/ng-select'; @@ -120,6 +121,7 @@ import { FormsModule } from '@angular/forms'; ProjectEditFieldComponent, UserEditFieldComponent, VersionsEditFieldComponent, + LabelsEditFieldComponent, WorkPackageEditFieldComponent, EditFormComponent, DisplayFieldComponent, diff --git a/spec/features/work_packages/details/inplace_editor/labels_editor_spec.rb b/spec/features/work_packages/details/inplace_editor/labels_editor_spec.rb new file mode 100644 index 000000000000..b1f149bab2cd --- /dev/null +++ b/spec/features/work_packages/details/inplace_editor/labels_editor_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "spec_helper" +require "features/work_packages/details/inplace_editor/shared_examples" +require "features/work_packages/shared_contexts" +require "support/edit_fields/edit_field" +require "features/work_packages/work_packages_page" + +RSpec.describe "labels inplace editor", :js, with_flag: :work_package_labels do + let(:project) { create(:project) } + let!(:label) { create(:label, name: "Bug") } + let!(:other_label) { create(:label, name: "Feature") } + let(:work_package) { create(:work_package, project:) } + let(:user) do + create(:user, + member_with_permissions: { project => %i[view_work_packages edit_work_packages] }) + end + + before do + create(:labeling, label:, labelable: work_package) + login_as(user) + end + + context "in the full view" do + let(:work_package_page) { Pages::FullWorkPackage.new(work_package) } + let(:field) { work_package_page.edit_field(:labels) } + + before do + work_package_page.visit! + work_package_page.ensure_page_loaded + end + + it "allows picking a label and removing another in the same edit, saving in one request" do + field.expect_state_text(label.name) + + field.activate! + field.set_value(other_label.name) + field.expect_selected_values(label.name, other_label.name) + field.unset_value(label.name, multi: true) + field.submit_by_dashboard + + field.expect_state_text(other_label.name) + expect(field.field_container).to have_no_text(label.name) + expect(work_package.reload.labels).to contain_exactly(other_label) + end + end + + context "in the split view" do + let(:work_package_page) { Pages::PrimerizedSplitWorkPackage.new(work_package, project) } + let(:field) { work_package_page.edit_field(:labels) } + + before do + work_package_page.visit! + work_package_page.ensure_page_loaded + end + + it "allows picking another label and saving it" do + field.expect_state_text(label.name) + + field.activate! + field.set_value(other_label.name) + field.expect_selected_values(label.name, other_label.name) + field.submit_by_dashboard + + expect(work_package.reload.labels).to contain_exactly(label, other_label) + field.expect_state_text(other_label.name) + end + end +end diff --git a/spec/support/edit_fields/edit_field.rb b/spec/support/edit_fields/edit_field.rb index bfe5c5575bc5..397676a1df55 100644 --- a/spec/support/edit_fields/edit_field.rb +++ b/spec/support/edit_fields/edit_field.rb @@ -107,6 +107,14 @@ def expect_display_value(value) .to have_content(value) end + ## + # Checks that exactly the given values are currently selected in a + # multi-select field (ng-select in multiple mode), reading its value chips. + def expect_selected_values(*names) + expect(field_container).to have_css(".ng-value-label", count: names.size) + names.each { |name| expect(field_container).to have_css(".ng-value-label", text: name) } + end + ## # Activate the field and check it opened correctly # @return [EditField] self @@ -327,6 +335,8 @@ def derive_field_type "create-autocompleter" when :targetVersions "ng-select" + when :labels + "op-labels-autocompleter" when :project "op-project-autocompleter" when :activity From caf95652d79417cb9a1b6a902c60535bacd24351 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 23 Sep 2026 23:09:53 +0300 Subject: [PATCH 09/10] Create labels from the work package labels dropdown The create option is pinned below the options and only offered while the typed name matches no loaded label. The created label joins the open selection and is saved with the field. --- config/locales/js-en.yml | 1 + frontend/src/app/core/apiv3/api-v3.service.ts | 3 + .../create-label-button.component.html | 15 +++ .../create-label-button.component.ts | 118 ++++++++++++++++++ ...bels-autocompleter-template.component.html | 3 + ...labels-autocompleter-template.component.ts | 41 ++++++ .../labels-autocompleter.component.ts | 22 +++- .../openproject-autocompleter.module.ts | 8 ++ .../inplace_editor/labels_editor_spec.rb | 22 ++++ 9 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 frontend/src/app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component.html create mode 100644 frontend/src/app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component.ts create mode 100644 frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component.html create mode 100644 frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component.ts diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index 5d500c186c3d..c4cc12c55ae6 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -79,6 +79,7 @@ en: single: 'Select "%{name}"' remove: "Remove %{name}" autocompleter: + create_label: 'Create label "%{name}"' notFoundText: "No items found" placeholder: "Type to search" project: diff --git a/frontend/src/app/core/apiv3/api-v3.service.ts b/frontend/src/app/core/apiv3/api-v3.service.ts index 965968a0e959..25a5dd019548 100644 --- a/frontend/src/app/core/apiv3/api-v3.service.ts +++ b/frontend/src/app/core/apiv3/api-v3.service.ts @@ -175,6 +175,9 @@ export class ApiV3Service { // /api/v3/job_statuses public readonly job_statuses = this.apiV3CollectionEndpoint('job_statuses'); + // /api/v3/labels + public readonly labels = this.apiV3CollectionEndpoint('labels'); + // /api/v3/views public readonly views = this.apiV3CustomEndpoint(ApiV3ViewsPaths); diff --git a/frontend/src/app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component.html b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component.html new file mode 100644 index 000000000000..0295c7088d8e --- /dev/null +++ b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component.html @@ -0,0 +1,15 @@ +@if (autocompleter.loadedLabels$ | async; as items) { + @if (showCreateOption(items)) { + + } +} diff --git a/frontend/src/app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component.ts b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component.ts new file mode 100644 index 000000000000..ca82c4bbe16d --- /dev/null +++ b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component.ts @@ -0,0 +1,118 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, inject } from '@angular/core'; +import { finalize } from 'rxjs/operators'; +import { HttpClient } from '@angular/common/http'; +import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service'; +import { + OpAutocompleterComponent, +} from 'core-app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component'; +import { + IApiLabel, + ILabelAutocompleteItem, + LabelsAutocompleterComponent, +} from 'core-app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component'; + +@Component({ + selector: 'op-create-label-button', + templateUrl: './create-label-button.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, +}) +export class CreateLabelButtonComponent { + @Input() searchTerm = ''; + + readonly I18n = inject(I18nService); + readonly http = inject(HttpClient); + readonly apiV3Service = inject(ApiV3Service); + readonly halNotification = inject(HalResourceNotificationService); + readonly cdRef = inject(ChangeDetectorRef); + readonly autocompleter = inject(OpAutocompleterComponent) as LabelsAutocompleterComponent; + + public creating = false; + + public get trimmedSearchTerm():string { + return (this.searchTerm ?? '').trim(); + } + + public buttonText():string { + return this.I18n.t('js.autocompleter.create_label', { name: this.trimmedSearchTerm }); + } + + public showCreateOption(items:ILabelAutocompleteItem[]|null):boolean { + const term = this.trimmedSearchTerm; + if (!term) { + return false; + } + + const normalizedTerm = this.normalize(term); + return !(items ?? []).some((item) => this.normalize(item.name) === normalizedTerm); + } + + public onCreateClick(event:Event):void { + event.stopPropagation(); + const name = this.trimmedSearchTerm; + if (!name || this.creating) { + return; + } + + this.creating = true; + + this + .http + .post(this.apiV3Service.labels.toString(), { name }) + .pipe( + finalize(() => { + this.creating = false; + this.cdRef.markForCheck(); + }), + ) + .subscribe({ + next: (label) => this.addLabel(label), + error: (error) => this.halNotification.handleRawError(error), + }); + } + + private addLabel(label:IApiLabel):void { + const created:ILabelAutocompleteItem = { id: label.id, name: label.name, href: label._links.self.href }; + const current = Array.isArray(this.autocompleter.model) ? this.autocompleter.model : []; + + if (!current.some((item) => item.href === created.href)) { + this.autocompleter.changed([...current, created]); + } + + this.autocompleter.ngSelectInstance.filter(''); + } + + private normalize(value:string):string { + return value.trim().replace(/\s+/g, ' ').toLowerCase(); + } +} diff --git a/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component.html b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component.html new file mode 100644 index 000000000000..067223ac18fa --- /dev/null +++ b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component.html @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component.ts b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component.ts new file mode 100644 index 000000000000..6fa2d8899007 --- /dev/null +++ b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component.ts @@ -0,0 +1,41 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { ChangeDetectionStrategy, Component, TemplateRef, ViewChild } from '@angular/core'; +import { + IAutocompleterTemplateComponent, +} from 'core-app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component'; + +@Component({ + templateUrl: './labels-autocompleter-template.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, +}) +export class LabelsAutocompleterTemplateComponent implements IAutocompleterTemplateComponent { + @ViewChild('footerTemplate') footerTemplate?:TemplateRef; +} diff --git a/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component.ts b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component.ts index d16cdcc1292a..441a675c3017 100644 --- a/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component.ts +++ b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component.ts @@ -26,10 +26,10 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { keyBy } from 'lodash-es'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { map, tap } from 'rxjs/operators'; import { OpAutocompleterComponent, } from 'core-app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component'; @@ -37,6 +37,9 @@ import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter import { addFiltersToPath } from 'core-app/core/apiv3/helpers/add-filters-to-path'; import { IHALCollection } from 'core-app/core/apiv3/types/hal-collection.type'; import { compareByAttribute } from 'core-app/shared/helpers/angular/tracking-functions'; +import { + LabelsAutocompleterTemplateComponent, +} from 'core-app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component'; export const labelsAutocompleterSelector = 'op-labels-autocompleter'; @@ -46,7 +49,7 @@ export interface ILabelAutocompleteItem { href:string|null; } -interface IApiLabel { +export interface IApiLabel { id:string|number; name:string; _links:{ self:{ href:string|null } }; @@ -58,9 +61,17 @@ interface IApiLabel { changeDetection: ChangeDetectionStrategy.OnPush, standalone: false, }) -export class LabelsAutocompleterComponent extends OpAutocompleterComponent { +export class LabelsAutocompleterComponent extends OpAutocompleterComponent implements OnInit { getOptionsFn = this.getLabels.bind(this); + // Latest options from the server; results$ is cold and re-runs the request per subscriber. + public readonly loadedLabels$ = new BehaviorSubject([]); + + ngOnInit():void { + super.ngOnInit(); + this.applyTemplates(LabelsAutocompleterTemplateComponent); + } + public getLabels(searchTerm?:string):Observable { const filterObject = keyBy(this.filters, 'name'); const filters = ApiV3FilterBuilder.fromFilterObject(filterObject); @@ -77,6 +88,7 @@ export class LabelsAutocompleterComponent extends OpAutocompleterComponent>(filteredURL.toString()) .pipe( map((res) => res._embedded.elements.map((label) => ({ id: label.id, name: label.name, href: label._links.self.href }))), + tap((labels) => this.loadedLabels$.next(labels)), ); } diff --git a/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts b/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts index 6c9a1e8348d4..3d4ec43acb72 100644 --- a/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts +++ b/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts @@ -51,6 +51,12 @@ import { import { LabelsAutocompleterComponent, } from 'core-app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component'; +import { + LabelsAutocompleterTemplateComponent, +} from 'core-app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component'; +import { + CreateLabelButtonComponent, +} from 'core-app/shared/components/autocompleter/labels-autocompleter/create-label-button/create-label-button.component'; import { MeetingAutocompleterComponent, } from 'core-app/shared/components/autocompleter/meeting-autocompleter/meeting-autocompleter.component'; @@ -113,6 +119,8 @@ export const OPENPROJECT_AUTOCOMPLETE_COMPONENTS = [ UserAutocompleterComponent, UserAutocompleterTemplateComponent, LabelsAutocompleterComponent, + LabelsAutocompleterTemplateComponent, + CreateLabelButtonComponent, ResourceAllocationAutocompleterComponent, ResourceAllocationAutocompleterTemplateComponent, MeetingAutocompleterTemplateComponent, diff --git a/spec/features/work_packages/details/inplace_editor/labels_editor_spec.rb b/spec/features/work_packages/details/inplace_editor/labels_editor_spec.rb index b1f149bab2cd..6b2d404ec951 100644 --- a/spec/features/work_packages/details/inplace_editor/labels_editor_spec.rb +++ b/spec/features/work_packages/details/inplace_editor/labels_editor_spec.rb @@ -43,6 +43,28 @@ expect(field.field_container).to have_no_text(label.name) expect(work_package.reload.labels).to contain_exactly(other_label) end + + it "offers to create a new label from the search term and adds it to the selection" do + new_label_name = "Urgent" + create_button_text = I18n.t("js.autocompleter.create_label", name: new_label_name) + duplicate_button_text = I18n.t("js.autocompleter.create_label", name: other_label.name.upcase) + + field.activate! + + dropdown = field.autocomplete(other_label.name.upcase, select: false) + expect(dropdown).to have_no_button(duplicate_button_text) + + dropdown = field.autocomplete(new_label_name, select: false) + expect(dropdown).to have_button(create_button_text) + dropdown.click_button(create_button_text) + + field.expect_selected_values(label.name, new_label_name) + field.submit_by_dashboard + + field.expect_state_text(new_label_name) + expect(Label.named(new_label_name)).to be_present + expect(work_package.reload.labels.map(&:name)).to contain_exactly(label.name, new_label_name) + end end context "in the split view" do From c8eb2b196750c5a73b2cf3b1abb6666b6ce91216 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 23 Sep 2026 23:18:35 +0300 Subject: [PATCH 10/10] Register the label write model in the OpenAPI spec --- docs/api/apiv3/openapi-spec.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/api/apiv3/openapi-spec.yml b/docs/api/apiv3/openapi-spec.yml index b6861011778c..3fcea0c082b2 100644 --- a/docs/api/apiv3/openapi-spec.yml +++ b/docs/api/apiv3/openapi-spec.yml @@ -932,6 +932,8 @@ components: "$ref": "./components/schemas/label_collection_model.yml" LabelModel: "$ref": "./components/schemas/label_model.yml" + LabelWriteModel: + "$ref": "./components/schemas/label_write_model.yml" Link: "$ref": "./components/schemas/link.yml" List_actionsModel: