diff --git a/app/models/label.rb b/app/models/label.rb index c2e59bff3ef9..af867cc9bc4f 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -32,8 +32,24 @@ 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("labelings.label_id = labels.id") + .joins("INNER JOIN work_packages ON work_packages.id = labelings.labelable_id") + .where(work_packages: { project_id: project }) + .arel + .exists + + reorder(used_in_project.desc) + .order(Arel.sql("#{USAGE_COUNT_SQL} DESC")) + .order("LOWER(labels.name) ASC") } normalizes :name, with: -> { it.squish } diff --git a/app/models/type/attribute_groups.rb b/app/models/type/attribute_groups.rb index fd40a30aaf4b..d588b6a86ad1 100644 --- a/app/models/type/attribute_groups.rb +++ b/app/models/type/attribute_groups.rb @@ -50,6 +50,7 @@ module Type::AttributeGroups remaining_time: :estimates_and_progress, percentage_done: :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/models/type/attributes.rb b/app/models/type/attributes.rb index 4de6ef25e12c..de85d76d8bbe 100644 --- a/app/models/type/attributes.rb +++ b/app/models/type/attributes.rb @@ -86,7 +86,8 @@ def all_work_package_form_attributes(merge_date: false) OpenProject::Cache.fetch_request_cached("all_work_package_form_attributes", *wp_cf_cache_parts, EXCLUDED.length, - merge_date) do + merge_date, + OpenProject::FeatureDecisions.work_package_labels_active?) do calculate_all_work_package_form_attributes(merge_date) end end @@ -139,6 +140,10 @@ def skipped_attribute?(key, definition) # We always want to include the priority even if its required return false if key == "priority" + # Remove once the work_package_labels feature flag is removed; show_if + # on the schema representer property is never evaluated here. + return true if key == "labels" && !OpenProject::FeatureDecisions.work_package_labels_active? + EXCLUDED.include?(key) || definition[:required] end diff --git a/app/models/work_package/exports/formatters/labels.rb b/app/models/work_package/exports/formatters/labels.rb new file mode 100644 index 000000000000..f1bde1f52ca4 --- /dev/null +++ b/app/models/work_package/exports/formatters/labels.rb @@ -0,0 +1,43 @@ +# 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 WorkPackage::Exports + module Formatters + class Labels < ::Exports::Formatters::Default + def self.apply?(attribute, _export_format) + attribute.to_sym == :labels + end + + def retrieve_value(object) + object.labels.map(&:name) + end + end + end +end diff --git a/config/initializers/export_formats.rb b/config/initializers/export_formats.rb index 1dbfb575ddab..1d205bb76c93 100644 --- a/config/initializers/export_formats.rb +++ b/config/initializers/export_formats.rb @@ -49,6 +49,7 @@ formatter WorkPackage, WorkPackage::Exports::Formatters::XLS::DoneRatio formatter WorkPackage, WorkPackage::Exports::Formatters::PDF::Hours formatter WorkPackage, WorkPackage::Exports::Formatters::Id + formatter WorkPackage, WorkPackage::Exports::Formatters::Labels formatter WorkPackage, WorkPackage::Exports::Formatters::ProjectPhase formatter WorkPackage, WorkPackage::Exports::Formatters::SpentUnits formatter WorkPackage, WorkPackage::Exports::Formatters::TargetVersions diff --git a/docs/api/apiv3/openapi-spec.yml b/docs/api/apiv3/openapi-spec.yml index a6a0360174bd..0eb33dc8c64c 100644 --- a/docs/api/apiv3/openapi-spec.yml +++ b/docs/api/apiv3/openapi-spec.yml @@ -665,6 +665,8 @@ paths: "$ref": "./paths/workspace_work_packages_form.yml" "/api/v3/workspaces/{id}/versions": "$ref": "./paths/workspace_versions.yml" + "/api/v3/workspaces/{id}/labels": + "$ref": "./paths/workspace_labels.yml" "/api/v3/workspaces/schema": "$ref": "./paths/workspaces_schema.yml" diff --git a/docs/api/apiv3/paths/workspace_labels.yml b/docs/api/apiv3/paths/workspace_labels.yml new file mode 100644 index 000000000000..4b68b9c81019 --- /dev/null +++ b/docs/api/apiv3/paths/workspace_labels.yml @@ -0,0 +1,63 @@ +# /api/v3/workspaces/{id}/labels +--- +get: + parameters: + - description: ID of the workspace whose labels will be listed + example: 1 + in: path + name: id + required: true + schema: + type: integer + - name: filters + description: |- + JSON specifying filter conditions. + Currently supported filters are: + + + name: filters labels by name, with the operators `~` (contains), `!~` (does not contain) and `**` (all) + example: '[{ "name": { "operator": "~", "values": ["bug"] } }]' + in: query + required: false + schema: + type: string + responses: + '200': + content: + application/hal+json: + examples: + 'simple label collection': + $ref: "../components/examples/label_collection.yml" + schema: + "$ref": "../components/schemas/label_collection_model.yml" + description: OK + headers: {} + '400': + $ref: "../components/responses/invalid_query.yml" + '404': + content: + application/hal+json: + schema: + $ref: "../components/schemas/error_response.yml" + examples: + response: + value: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:NotFound + message: The specified workspace does not exist. + description: |- + Returned if the workspace does not exist or the client does not have sufficient permissions + to see it. + + **Required permission:** view work packages (on given workspace) + + *Note: A client without sufficient permissions shall not be able to test for the existence of a workspace. + That's why a 404 is returned here, even if a 403 might be more appropriate.* + headers: {} + tags: + - Labels + description: |- + Returns a paginated collection of all labels, ordered by relevance for the given workspace: + labels already used on work packages of the workspace come first, followed by the remaining labels. + Within each group, labels are ordered by how often they are used overall and then by name. + operationId: List_labels_by_workspace + summary: List labels by workspace diff --git a/lib/api/v3/labels/labels_by_workspace_api.rb b/lib/api/v3/labels/labels_by_workspace_api.rb new file mode 100644 index 000000000000..8b9ca64b74da --- /dev/null +++ b/lib/api/v3/labels/labels_by_workspace_api.rb @@ -0,0 +1,50 @@ +# 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) + end + + get &::API::V3::Utilities::Endpoints::Index.new(model: Label, + scope: -> { Label.ordered_by_relevance_for(@project) }, + self_path: -> { api_v3_paths.labels_by_workspace(@project.id) }) + .mount + end + end + end + end +end diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index 3614feeccb30..a3149ccb8d30 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -656,6 +656,10 @@ def self.views_type(type) index :label show :label + 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 4861db10d6a7..68015ec8a612 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 @@ -279,6 +280,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) { @@ -460,6 +467,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/lib/api/v3/utilities/path_helper_spec.rb b/spec/lib/api/v3/utilities/path_helper_spec.rb index 24e704e3b10a..e527d8f85e0f 100644 --- a/spec/lib/api/v3/utilities/path_helper_spec.rb +++ b/spec/lib/api/v3/utilities/path_helper_spec.rb @@ -491,6 +491,12 @@ it_behaves_like "api v3 path", "/versions/available_projects" 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 090a6e59e34b..c017e6aae53b 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 @@ -1210,6 +1210,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/models/type/attributes_spec.rb b/spec/models/type/attributes_spec.rb index 48ea986e2052..8a01d79af27e 100644 --- a/spec/models/type/attributes_spec.rb +++ b/spec/models/type/attributes_spec.rb @@ -48,6 +48,26 @@ expect(attributes).not_to have_key("version") end end + + context "when the work package labels feature is active", with_flag: :work_package_labels do + it "offers labels" do + expect(attributes).to have_key("labels") + end + end + + context "when the work package labels feature is inactive", with_flag: { work_package_labels: false } do + it "hides labels" do + expect(attributes).not_to have_key("labels") + end + end + + it "keys the cache on the feature flag state" do + allow(OpenProject::FeatureDecisions).to receive(:work_package_labels_active?).and_return(false) + expect(TypeVariant.all_work_package_form_attributes).not_to have_key("labels") + + allow(OpenProject::FeatureDecisions).to receive(:work_package_labels_active?).and_return(true) + expect(TypeVariant.all_work_package_form_attributes).to have_key("labels") + end end describe ".translated_work_package_form_attributes" do diff --git a/spec/models/work_package/exports/formatters/labels_spec.rb b/spec/models/work_package/exports/formatters/labels_spec.rb new file mode 100644 index 000000000000..1eaf896ee904 --- /dev/null +++ b/spec/models/work_package/exports/formatters/labels_spec.rb @@ -0,0 +1,74 @@ +# 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 WorkPackage::Exports::Formatters::Labels do + let(:formatter_instance) { described_class.new(:labels) } + + describe ".apply?" do + it "returns true for :labels with any export format" do + expect(described_class.apply?(:labels, :pdf)).to be true + expect(described_class.apply?(:labels, :csv)).to be true + expect(described_class.apply?(:labels, :xls)).to be true + end + + it "returns false for other attributes" do + expect(described_class.apply?(:subject, :pdf)).to be false + end + end + + describe "#format" do + let(:labels) { [build_stubbed(:label, name: "Urgent"), build_stubbed(:label, name: "Frontend")] } + let(:work_package) do + build_stubbed(:work_package) do |wp| + allow(wp) + .to receive(:labels) + .and_return(labels) + end + end + + it "returns the joined label names" do + expect(formatter_instance.format(work_package)).to eq("Urgent, Frontend") + end + + it "honors the array_separator option" do + expect(formatter_instance.format(work_package, array_separator: "; ")).to eq("Urgent; Frontend") + end + + context "without labels" do + let(:labels) { [] } + + it "returns an empty string" do + expect(formatter_instance.format(work_package)).to eq("") + end + end + end +end diff --git a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb index 1a92c0aef444..a286ef0f8db6 100644 --- a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb +++ b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb @@ -333,6 +333,18 @@ def remove_pdf_page_footers(strings, nr_of_pages) end end + describe "with the work package labels feature active", with_flag: { work_package_labels: true } do + before do + create(:labeling, label: create(:label, name: "Urgent"), labelable: work_package) + end + + it "renders the labels attribute" do + result = remove_pdf_page_footers(pdf[:strings].join(" "), 2) + + expect(result).to include("#{WorkPackage.human_attribute_name(:labels)} Urgent") + end + end + describe "with faulty images" do before do # simulate a null pointer exception 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..065c2b8afc44 --- /dev/null +++ b/spec/requests/api/v3/workspaces/label_resource_spec.rb @@ -0,0 +1,139 @@ +# 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 "with a page size smaller than the number of labels" do + current_user { permitted_user } + + before do + get "#{get_path}?pageSize=2" + end + + it_behaves_like "API V3 collection response", 3, 2, "Label" do + let(:elements) { [local_label, popular_elsewhere] } + end + + it "links to the next page" do + expect(response.body).to have_json_path("_links/nextByOffset/href") + end + end + + context "with pageSize -1 requesting the maximum page size" do + current_user { permitted_user } + + before do + get "#{get_path}?pageSize=-1" + end + + it_behaves_like "API V3 collection response", 3, 3, "Label" do + let(:elements) { [local_label, popular_elsewhere, unused_label] } + end + + it "resolves to the configured maximum page size" do + expect(response.body).to be_json_eql(Setting.apiv3_max_page_size.to_i.to_json).at_path("pageSize") + 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 +end