Skip to content
Closed
23 changes: 22 additions & 1 deletion app/models/label.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
1 change: 1 addition & 0 deletions app/models/type/attribute_groups.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
11 changes: 11 additions & 0 deletions app/services/labels/create_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
59 changes: 59 additions & 0 deletions app/services/labels/find_or_create_service.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions config/locales/js-en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions docs/api/apiv3/components/schemas/label_write_model.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions docs/api/apiv3/openapi-spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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:
Expand Down
65 changes: 65 additions & 0 deletions docs/api/apiv3/paths/labels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
63 changes: 63 additions & 0 deletions docs/api/apiv3/paths/workspace_labels.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion docs/api/apiv3/tags/labels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions frontend/src/app/core/apiv3/api-v3.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
@if (autocompleter.loadedLabels$ | async; as items) {
@if (showCreateOption(items)) {
<button
class="op-select-footer--label"
type="button"
[disabled]="creating"
(click)="onCreateClick($event)"
>
<span class="icon-context">
<op-icon icon-classes="icon-add icon-context" />
{{ buttonText() }}
</span>
</button>
}
}
Loading
Loading