diff --git a/app/models/label.rb b/app/models/label.rb
index c2e59bff3ef9..e46dc86db169 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -32,8 +32,29 @@ 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)
+ }
+
+ scope :named, ->(name) {
+ where("LOWER(labels.name) = LOWER(?)", normalize_value_for(:name, name))
}
normalizes :name, with: -> { it.squish }
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/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
new file mode 100644
index 000000000000..e4bc5c558dcf
--- /dev/null
+++ b/app/services/labels/find_or_create_service.rb
@@ -0,0 +1,59 @@
+# 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 = CreateService.new(user:).call(name:)
+ return call if call.success? || call.errors.symbols_for(:name).exclude?(:taken)
+
+ existing(name)
+ end
+
+ def existing(name)
+ ServiceResult.success(result: Label.named(name).first!)
+ end
+ end
+end
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/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/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/openapi-spec.yml b/docs/api/apiv3/openapi-spec.yml
index 51b0fd9aaaad..3fcea0c082b2 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"
@@ -930,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:
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/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/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
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
new file mode 100644
index 000000000000..441a675c3017
--- /dev/null
+++ b/frontend/src/app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component.ts
@@ -0,0 +1,102 @@
+//-- 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, OnInit } from '@angular/core';
+import { keyBy } from 'lodash-es';
+import { BehaviorSubject, Observable } from 'rxjs';
+import { map, tap } from 'rxjs/operators';
+import {
+ OpAutocompleterComponent,
+} from 'core-app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component';
+import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder';
+import { addFiltersToPath } from 'core-app/core/apiv3/helpers/add-filters-to-path';
+import { IHALCollection } from 'core-app/core/apiv3/types/hal-collection.type';
+import { compareByAttribute } from 'core-app/shared/helpers/angular/tracking-functions';
+import {
+ LabelsAutocompleterTemplateComponent,
+} from 'core-app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component';
+
+export const labelsAutocompleterSelector = 'op-labels-autocompleter';
+
+export interface ILabelAutocompleteItem {
+ id:string|number;
+ name:string;
+ href:string|null;
+}
+
+export interface IApiLabel {
+ id:string|number;
+ name:string;
+ _links:{ self:{ href:string|null } };
+}
+
+@Component({
+ templateUrl: '../op-autocompleter/op-autocompleter.component.html',
+ selector: labelsAutocompleterSelector,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ standalone: false,
+})
+export class LabelsAutocompleterComponent extends OpAutocompleterComponent implements OnInit {
+ getOptionsFn = this.getLabels.bind(this);
+
+ // 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);
+ 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 }))),
+ tap((labels) => this.loadedLabels$.next(labels)),
+ );
+ }
+
+ 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..3d4ec43acb72 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,15 @@ import {
import {
UserAutocompleterComponent,
} from 'core-app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component';
+import {
+ LabelsAutocompleterComponent,
+} from 'core-app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter.component';
+import {
+ LabelsAutocompleterTemplateComponent,
+} from 'core-app/shared/components/autocompleter/labels-autocompleter/labels-autocompleter-template.component';
+import {
+ 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';
@@ -109,6 +118,9 @@ export const OPENPROJECT_AUTOCOMPLETE_COMPONENTS = [
DraggableAutocompleteComponent,
UserAutocompleterComponent,
UserAutocompleterTemplateComponent,
+ LabelsAutocompleterComponent,
+ LabelsAutocompleterTemplateComponent,
+ CreateLabelButtonComponent,
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/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/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 7ca50e7d3f36..03b2b5102974 100644
--- a/lib/api/v3/utilities/path_helper.rb
+++ b/lib/api/v3/utilities/path_helper.rb
@@ -633,6 +633,14 @@ def self.views_type(type)
index :label
show :label
+ 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/eager_loading/checksum.rb b/lib/api/v3/work_packages/eager_loading/checksum.rb
index e9bbf62a7b2d..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 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 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
@@ -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.polymorphic_name}' 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/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..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
@@ -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_workspace(project_id) if project_id
+ end
+
def assignee_user_autocompleter
work_package = represented.work_package
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/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..6b2d404ec951
--- /dev/null
+++ b/spec/features/work_packages/details/inplace_editor/labels_editor_spec.rb
@@ -0,0 +1,91 @@
+# 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
+
+ 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
+ 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/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/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
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..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
@@ -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_workspace(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] }
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/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/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
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) }
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
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