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