diff --git a/app/components/admin/labels/delete_dialog_component.html.erb b/app/components/admin/labels/delete_dialog_component.html.erb new file mode 100644 index 000000000000..1a16e20f81c1 --- /dev/null +++ b/app/components/admin/labels/delete_dialog_component.html.erb @@ -0,0 +1,47 @@ +<%#-- 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. + +++#%> + +<%= + render( + Primer::OpenProject::DangerDialog.new( + title: t(".title"), + confirm_button_text: t(:button_delete), + cancel_button_text: t(:button_close), + form_arguments:, + test_selector: TEST_SELECTOR + ) + ) do |dialog| + dialog.with_confirmation_message do |message| + message.with_heading(tag: :h2) { t(".heading") } + message.with_description_content(t(".description_html", name: label.name)) + end + + dialog.with_confirmation_check_box_content(t(".confirmation")) + end +%> diff --git a/app/components/admin/labels/delete_dialog_component.rb b/app/components/admin/labels/delete_dialog_component.rb new file mode 100644 index 000000000000..c12073d20b59 --- /dev/null +++ b/app/components/admin/labels/delete_dialog_component.rb @@ -0,0 +1,48 @@ +# 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 Admin + module Labels + class DeleteDialogComponent < ApplicationComponent + include OpTurbo::Streamable + + alias_method :label, :model + + TEST_SELECTOR = "op-labels--delete-dialog" + + def form_arguments + { + action: admin_label_path(label), + method: :delete + } + end + end + end +end diff --git a/app/components/admin/labels/dialog_component.html.erb b/app/components/admin/labels/dialog_component.html.erb new file mode 100644 index 000000000000..e877b1deed52 --- /dev/null +++ b/app/components/admin/labels/dialog_component.html.erb @@ -0,0 +1,60 @@ +<%#-- 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. + +++#%> + +<%= + render( + Primer::Alpha::Dialog.new( + title:, + size: :medium, + id: DIALOG_ID + ) + ) do |d| + d.with_header(variant: :large) + + d.with_body do + render(Admin::Labels::FormComponent.new(label:)) + end + + d.with_footer do + component_collection do |buttons| + buttons.with_component( + Primer::Beta::Button.new(data: { close_dialog_id: DIALOG_ID }) + ) do + t(:button_cancel) + end + + buttons.with_component( + Primer::Beta::Button.new(scheme: :primary, form: Admin::Labels::FormComponent::FORM_ID, type: :submit) + ) do + button_caption + end + end + end + end +%> diff --git a/app/components/admin/labels/dialog_component.rb b/app/components/admin/labels/dialog_component.rb new file mode 100644 index 000000000000..5514203699b9 --- /dev/null +++ b/app/components/admin/labels/dialog_component.rb @@ -0,0 +1,58 @@ +# 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 Admin + module Labels + class DialogComponent < ApplicationComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + DIALOG_ID = "admin-label-dialog" + + attr_reader :label + + def initialize(label:) + super() + + @label = label + end + + private + + def title + label.persisted? ? t(".rename_title", name: label.name) : t(".create_title") + end + + def button_caption + label.persisted? ? t(:button_rename) : t(:button_create) + end + end + end +end diff --git a/app/components/admin/labels/form_component.html.erb b/app/components/admin/labels/form_component.html.erb new file mode 100644 index 000000000000..0751e50355ee --- /dev/null +++ b/app/components/admin/labels/form_component.html.erb @@ -0,0 +1,43 @@ +<%#-- 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. + +++#%> + +<%= + component_wrapper do + primer_form_with( + id: FORM_ID, + model:, + scope: :label, + url: form_url, + method: http_method, + data: { turbo: true } + ) do |f| + render(Admin::Labels::Form.new(f)) + end + end +%> diff --git a/app/components/admin/labels/form_component.rb b/app/components/admin/labels/form_component.rb new file mode 100644 index 000000000000..c9268eb6f51a --- /dev/null +++ b/app/components/admin/labels/form_component.rb @@ -0,0 +1,56 @@ +# 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 Admin + module Labels + class FormComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + FORM_ID = "admin-label-form" + + # A `label` reader here would shadow ActionView::Helpers::FormHelper#label + # inside the primer_form_with block, so the record stays `model`. + def initialize(label:) + super(label) + end + + private + + def http_method + model.persisted? ? :patch : :post + end + + def form_url + model.persisted? ? admin_label_path(model) : admin_labels_path + end + end + end +end diff --git a/app/components/admin/labels/list_component.html.erb b/app/components/admin/labels/list_component.html.erb new file mode 100644 index 000000000000..5dfd7065c017 --- /dev/null +++ b/app/components/admin/labels/list_component.html.erb @@ -0,0 +1,34 @@ +<%#-- 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. + +++#%> + +<%= + component_wrapper do + render(Admin::Labels::TableComponent.new(rows: labels, query:)) + end +%> diff --git a/app/components/admin/labels/list_component.rb b/app/components/admin/labels/list_component.rb new file mode 100644 index 000000000000..4ccfc1b8b529 --- /dev/null +++ b/app/components/admin/labels/list_component.rb @@ -0,0 +1,42 @@ +# 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 Admin + module Labels + class ListComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + alias_method :labels, :model + + options :query + end + end +end diff --git a/app/components/admin/labels/row_component.rb b/app/components/admin/labels/row_component.rb new file mode 100644 index 000000000000..8db4d647cc91 --- /dev/null +++ b/app/components/admin/labels/row_component.rb @@ -0,0 +1,95 @@ +# 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 Admin + module Labels + class RowComponent < OpPrimer::BorderBoxRowComponent + alias_method :label, :model + + def row_data + { test_selector: "label-row-#{label.id}" } + end + + def name + render( + Primer::Beta::Label.new( + scheme: :secondary, + test_selector: "label-name", + classes: "ellipsis", + w: :fit, + title: label.name + ) + ) { label.name } + end + + def usage + render(Primer::Beta::Text.new(color: :subtle, test_selector: "label-usage")) { usage_text } + end + + def button_links + [action_menu] + end + + private + + # usage_count is a select-only virtual attribute present only on rows + # loaded through Label.with_usage_count; label[:usage_count] is nil otherwise. + def usage_text + count = label[:usage_count].to_i + count.zero? ? "-" : t(".used_in_work_packages", count:) + end + + def action_menu + render(Primer::Alpha::ActionMenu.new(anchor_align: :end, test_selector: "label-row-menu")) do |menu| + menu.with_show_button(icon: "kebab-horizontal", "aria-label": t(:label_more), scheme: :invisible) + + menu.with_item( + label: t(:button_rename), + tag: :a, + href: edit_dialog_admin_label_path(label), + content_arguments: { data: { controller: "async-dialog" } } + ) do |item| + item.with_leading_visual_icon(icon: :pencil) + end + + menu.with_item( + label: t(:button_delete), + scheme: :danger, + tag: :a, + href: deletion_dialog_admin_label_path(label), + content_arguments: { data: { controller: "async-dialog" } } + ) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + end + end + end +end diff --git a/app/components/admin/labels/sub_header_component.html.erb b/app/components/admin/labels/sub_header_component.html.erb new file mode 100644 index 000000000000..f746d1fa7e69 --- /dev/null +++ b/app/components/admin/labels/sub_header_component.html.erb @@ -0,0 +1,56 @@ +<%#-- 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. + +++#%> + +<%= + render(Primer::OpenProject::SubHeader.new(collapsed_search: false, data: sub_header_data_attributes)) do |subheader| + subheader.with_filter_input( + name: "name", + label: t(:label_search), + visually_hide_label: true, + value: filter_input_value, + placeholder: t(".search_placeholder"), + leading_visual: { icon: :search, size: :small }, + show_clear_button: true, + clear_button_id:, + data: filter_input_data_attributes + ) + + subheader.with_action_button( + scheme: :primary, + leading_icon: :plus, + label: t(".label_add"), + tag: :a, + href: new_dialog_admin_labels_path, + data: { controller: "async-dialog" }, + test_selector: "add-label-button" + ) do + t(".button_add") + end + end +%> diff --git a/app/components/admin/labels/sub_header_component.rb b/app/components/admin/labels/sub_header_component.rb new file mode 100644 index 000000000000..36477f168508 --- /dev/null +++ b/app/components/admin/labels/sub_header_component.rb @@ -0,0 +1,69 @@ +# 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 Admin + module Labels + class SubHeaderComponent < ApplicationComponent + def initialize(query:) + super() + + @query = query + end + + def filter_input_value + @query.find_active_filter(:name)&.values&.first + end + + def sub_header_data_attributes + { + controller: "filter--filters-form", + "filter--filters-form-turbo-stream-request-value": true, + "filter--filters-form-url-path-name-value": search_admin_labels_path, + "filter--filters-form-output-format-value": "json", + "filter--filters-form-clear-button-id-value": clear_button_id, + test_selector: "labels-sub-header" + } + end + + def filter_input_data_attributes + { + "filter-name": "name", + "filter-type": "string", + "filter-operator": "~", + "filter--filters-form-target": "simpleFilter filterValueContainer simpleValue" + } + end + + def clear_button_id + "labels-filters-form-clear-button" + end + end + end +end diff --git a/app/components/admin/labels/table_component.rb b/app/components/admin/labels/table_component.rb new file mode 100644 index 000000000000..bb31d19eab5f --- /dev/null +++ b/app/components/admin/labels/table_component.rb @@ -0,0 +1,71 @@ +# 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 Admin + module Labels + class TableComponent < OpPrimer::BorderBoxTableComponent + options :query + + columns :name, :usage + main_column :name + mobile_columns :name, :usage + mobile_labels :usage + + def has_actions? = true + + def headers + [ + [:name, { caption: t(".headers.name") }], + [:usage, { caption: t(".headers.usage") }] + ] + end + + def mobile_title = t(:label_label_plural) + + def filtered? + query.find_active_filter(:name).present? + end + + def blank_title + filtered? ? t(".no_matches.title") : t(".blank_slate.title") + end + + def blank_description + filtered? ? t(".no_matches.description") : t(".blank_slate.description") + end + + def blank_icon + filtered? ? :search : :tag + end + + def pagination_params = { params: { action: "index" } } + end + end +end diff --git a/app/controllers/admin/labels_controller.rb b/app/controllers/admin/labels_controller.rb new file mode 100644 index 000000000000..68aa3731cacd --- /dev/null +++ b/app/controllers/admin/labels_controller.rb @@ -0,0 +1,133 @@ +# 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 Admin + class LabelsController < ::ApplicationController + include OpTurbo::ComponentStream + include PaginationHelper + include ::Labels::LabelsFeature + + before_action :require_admin + before_action :require_work_package_labels_feature + before_action :find_label, only: %i[edit_dialog update deletion_dialog destroy] + + menu_item :labels + + layout "admin" + + def index + @query = ParamsToQueryService.new(Label, current_user, query_class: Queries::Labels::LabelQuery).call(params) + @labels = @query.results.paginate(page: page_param, per_page: per_page_param) + end + + def search + index + + replace_via_turbo_stream(component: Admin::Labels::ListComponent.new(@labels, query: @query)) + turbo_streams << turbo_stream.push_state(url_for(params.permit(:controller, :filters).merge(action: "index"))) + + respond_with_turbo_streams + end + + def new_dialog + respond_with_dialog Admin::Labels::DialogComponent.new(label: Label.new) + end + + def edit_dialog + respond_with_dialog Admin::Labels::DialogComponent.new(label: @label) + end + + def create + result = ::Labels::CreateService + .new(user: current_user) + .call(label_params) + + result.on_success { redirect_to_label_page(result.result, notice: t(:notice_successful_create)) } + + result.on_failure do + update_via_turbo_stream( + component: Admin::Labels::FormComponent.new(label: result.result), + status: :unprocessable_entity + ) + respond_with_turbo_streams + end + end + + def update + result = ::Labels::UpdateService + .new(user: current_user, model: @label) + .call(label_params) + + result.on_success { redirect_to_label_page(result.result, notice: t(:notice_successful_update)) } + + result.on_failure do + update_via_turbo_stream( + component: Admin::Labels::FormComponent.new(label: result.result), + status: :unprocessable_entity + ) + respond_with_turbo_streams + end + end + + def deletion_dialog + respond_with_dialog Admin::Labels::DeleteDialogComponent.new(@label) + end + + def destroy + result = ::Labels::DeleteService + .new(user: current_user, model: @label) + .call + + if result.success? + flash[:notice] = t(:notice_successful_delete) + else + flash[:error] = result.errors.full_messages + end + + redirect_to admin_labels_path + end + + private + + def find_label + @label = Label.find(params.expect(:id)) + end + + def redirect_to_label_page(label, notice:) + flash[:notice] = notice + page = Label.page_of(label, per_page: per_page_param) + redirect_to admin_labels_path(page: page > 1 ? page : nil) + end + + def label_params + params.expect(label: [:name]) + end + end +end diff --git a/app/controllers/concerns/labels/labels_feature.rb b/app/controllers/concerns/labels/labels_feature.rb new file mode 100644 index 000000000000..362bb2bbe642 --- /dev/null +++ b/app/controllers/concerns/labels/labels_feature.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 Labels + module LabelsFeature + extend ActiveSupport::Concern + + private + + def require_work_package_labels_feature + render_404 unless OpenProject::FeatureDecisions.work_package_labels_active? + end + end +end diff --git a/app/forms/admin/labels/form.rb b/app/forms/admin/labels/form.rb new file mode 100644 index 000000000000..7165e399150f --- /dev/null +++ b/app/forms/admin/labels/form.rb @@ -0,0 +1,44 @@ +# 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 Admin + module Labels + class Form < ApplicationForm + form do |f| + f.text_field( + name: :name, + label: attribute_name(:name), + required: true, + autofocus: true + ) + end + end + end +end diff --git a/app/models/label.rb b/app/models/label.rb index 763f5b5d9d29..c2e59bff3ef9 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -42,4 +42,8 @@ class Label < ApplicationRecord presence: true, uniqueness: { case_sensitive: false }, length: { maximum: 255 } + + def self.page_of(label, per_page:) + (where("LOWER(labels.name) < LOWER(?)", label.name).count / per_page) + 1 + end end diff --git a/app/views/admin/labels/index.html.erb b/app/views/admin/labels/index.html.erb new file mode 100644 index 000000000000..c0226dbc6ee2 --- /dev/null +++ b/app/views/admin/labels/index.html.erb @@ -0,0 +1,45 @@ +<%#-- 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. + +++#%> + +<% html_title t(:label_administration), t(:label_label_plural) %> + +<%= + render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title { t(:label_label_plural) } + header.with_description { t(".description") } + header.with_breadcrumbs( + [{ href: admin_index_path, text: t(:label_administration) }, + t(:label_label_plural)] + ) + end +%> + +<%= render(Admin::Labels::SubHeaderComponent.new(query: @query)) %> + +<%= render(Admin::Labels::ListComponent.new(@labels, query: @query)) %> diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb index 7ce166238310..952051b1e61d 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -485,6 +485,13 @@ icon: "op-custom-fields", html: { class: "custom_fields" } + menu.push :labels, + { controller: "/admin/labels", action: :index }, + after: :custom_fields, + if: ->(_) { User.current.admin? && OpenProject::FeatureDecisions.work_package_labels_active? }, + caption: :label_label_plural, + icon: "tag" + menu.push :custom_actions, { controller: "/custom_actions" }, if: ->(_) { User.current.admin? }, diff --git a/config/locales/en.yml b/config/locales/en.yml index c34233ca80ff..44300648ab5c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -705,6 +705,13 @@ en: not_unique: "is already in use. Please select another name." jira: invalid_protocol: "Please provide a valid protocol (http or https)" + label: + attributes: + name: + blank: "Name cannot be empty." + format: "%{message}" + taken: "A label with this name already exists. Please use another one." + too_long: "Name is too long (maximum is %{count} characters)." ldap_auth_source: attributes: tls_certificate_string: @@ -1754,6 +1761,36 @@ en: caption_with_maximum: > User actions on a work package (changing description, status, values, or writing comments) are grouped if performed within this period. It also controls notification and [webhook](webhook_link) delays. The maximum is %{max} minutes. + labels: + delete_dialog_component: + confirmation: "I understand that this action is not reversible" + description_html: "This will remove the label %{name} from all work packages in all projects." + heading: "Delete this label?" + title: "Delete this label?" + dialog_component: + create_title: "Create label" + rename_title: 'Rename "%{name}"' + index: + description: "All users able to edit work packages can freely tag work packages with either existing labels or create new ones instantly. This includes all labels created by users across all projects." + row_component: + used_in_work_packages: + one: "%{count} work package" + other: "%{count} work packages" + sub_header_component: + button_add: "Label" + label_add: "Add label" + search_placeholder: "Type here to search" + table_component: + blank_slate: + description: "Labels created by users on work packages will appear here." + title: "No labels yet" + headers: + name: "Label" + usage: "Used in" + no_matches: + description: "Try a different search term." + title: "No labels match your search" + mcp_configurations: index: description: "The model context protocol allows AI agents to provide its users with tools and resources exposed by this OpenProject instance. This feature is still in beta." @@ -3783,6 +3820,7 @@ en: label_journal_diff: "Description Comparison" label_jump_to_a_project: "Jump to a project..." label_keyword_plural: "Keywords" + label_label_plural: "Labels" label_language: "Language" label_language_based: "Based on user's language" label_languages: "Languages" diff --git a/config/routes.rb b/config/routes.rb index efeff3a3bf69..dea0fd211987 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1037,6 +1037,17 @@ end end + resources :labels, only: %i[index create update destroy] do + collection do + get :search, defaults: { format: :turbo_stream } + get :new_dialog, defaults: { format: :turbo_stream } + end + member do + get :edit_dialog, defaults: { format: :turbo_stream } + get :deletion_dialog, defaults: { format: :turbo_stream } + end + end + resource :backups, controller: "/admin/backups", only: %i[show] do collection do get :reset_token_dialog diff --git a/spec/components/admin/labels/delete_dialog_component_spec.rb b/spec/components/admin/labels/delete_dialog_component_spec.rb new file mode 100644 index 000000000000..799a9b8b2e32 --- /dev/null +++ b/spec/components/admin/labels/delete_dialog_component_spec.rb @@ -0,0 +1,78 @@ +# 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 "rails_helper" + +RSpec.describe Admin::Labels::DeleteDialogComponent, type: :component do + include Rails.application.routes.url_helpers + + subject(:rendered_component) do + with_request_url "/admin/labels" do + render_inline(described_class.new(label)) + end + end + + let(:label) { create(:label, name: "Machine Learning") } + + it "renders the confirmation heading and the description with the label name in bold" do + rendered_component + + expect(page).to have_css("h2", text: "Delete this label?") + expect(page).to have_text("This will remove the label Machine Learning from all work packages in all projects.") + expect(page).to have_css("strong", text: "Machine Learning") + end + + it "renders the required confirmation checkbox" do + rendered_component + + expect(page).to have_field("I understand that this action is not reversible", type: "checkbox") + end + + it "submits a DELETE to the label's own path" do + rendered_component + + expect(page).to have_css("form[action='#{admin_label_path(label)}']") + expect(page).to have_css("input[name='_method'][value='delete']", visible: :all) + end + + it "labels the confirm button Delete rather than Delete permanently" do + rendered_component + + expect(page).to have_button("Delete") + expect(page).to have_no_button("Delete permanently") + end + + it "labels the cancel button Close" do + rendered_component + + expect(page).to have_button("Close") + expect(page).to have_no_button("Cancel") + end +end diff --git a/spec/components/admin/labels/dialog_component_spec.rb b/spec/components/admin/labels/dialog_component_spec.rb new file mode 100644 index 000000000000..89b85025ef62 --- /dev/null +++ b/spec/components/admin/labels/dialog_component_spec.rb @@ -0,0 +1,79 @@ +# 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 "rails_helper" + +RSpec.describe Admin::Labels::DialogComponent, type: :component do + include Rails.application.routes.url_helpers + + subject(:rendered_component) do + with_request_url "/admin/labels" do + render_inline(described_class.new(label:)) + end + end + + context "for a new label" do + let(:label) { Label.new } + + it "renders the create title" do + rendered_component + + expect(page).to have_css("h1", text: "Create label") + end + + it "submits via a Create button bound to the form" do + rendered_component + + expect(page).to have_css("button[form='admin-label-form'][type='submit']", text: "Create") + end + + it "renders a Cancel button that closes the dialog" do + rendered_component + + expect(page).to have_css("button[data-close-dialog-id='admin-label-dialog']", text: "Cancel") + end + end + + context "for a persisted label" do + let(:label) { create(:label, name: "Machine Learning") } + + it "renders the rename title with the label's name" do + rendered_component + + expect(page).to have_css("h1", text: 'Rename "Machine Learning"') + end + + it "submits via a Rename button bound to the form" do + rendered_component + + expect(page).to have_css("button[form='admin-label-form'][type='submit']", text: "Rename") + end + end +end diff --git a/spec/components/admin/labels/form_component_spec.rb b/spec/components/admin/labels/form_component_spec.rb new file mode 100644 index 000000000000..327b7379d45d --- /dev/null +++ b/spec/components/admin/labels/form_component_spec.rb @@ -0,0 +1,92 @@ +# 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 "rails_helper" + +RSpec.describe Admin::Labels::FormComponent, type: :component do + include Rails.application.routes.url_helpers + + subject(:rendered_component) do + with_request_url "/admin/labels" do + render_inline(described_class.new(label:)) + end + end + + context "for a new label" do + let(:label) { Label.new } + + it "posts to the labels collection route" do + rendered_component + + expect(page).to have_css("form[action='#{admin_labels_path}'][method='post']") + end + + it "renders the empty Name field" do + rendered_component + + expect(page.find_field("Name").value).to be_blank + end + end + + context "for a persisted label" do + let(:label) { create(:label, name: "Machine Learning") } + + it "patches the label's own route" do + rendered_component + + expect(page).to have_css("form[action='#{admin_label_path(label)}']") + expect(page).to have_css("input[name='_method'][value='patch']", visible: :all) + end + + it "renders the Name field prefilled with the current name" do + rendered_component + + expect(page).to have_field("Name", with: "Machine Learning") + end + end + + context "with a validation error on the name" do + let(:label) { build(:label, name: "Bug") } + + before { label.errors.add(:name, :taken) } + + it "still posts to the labels collection route" do + rendered_component + + expect(page).to have_css("form[action='#{admin_labels_path}'][method='post']") + end + + it "renders the error message next to the field" do + rendered_component + + expect(page).to have_text("A label with this name already exists. Please use another one.") + end + end +end diff --git a/spec/components/admin/labels/list_component_spec.rb b/spec/components/admin/labels/list_component_spec.rb new file mode 100644 index 000000000000..06065003e025 --- /dev/null +++ b/spec/components/admin/labels/list_component_spec.rb @@ -0,0 +1,58 @@ +# 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 "rails_helper" + +RSpec.describe Admin::Labels::ListComponent, type: :component do + include Rails.application.routes.url_helpers + + let(:labels) { Label.with_usage_count.order(:name).paginate(page: 1, per_page: 10) } + let(:query) { Queries::Labels::LabelQuery.new } + + subject(:rendered_component) do + with_request_url "/admin/labels" do + render_inline(described_class.new(labels, query:)) + end + end + + it "renders a component_wrapper div with a stable DOM id" do + wrapper_id = described_class.new(labels, query:).wrapper_key + expect(rendered_component).to have_css("##{wrapper_id}") + end + + context "with labels" do + shared_let(:label) { create(:label, name: "Bug") } + + it "renders the table" do + expect(rendered_component).to have_selector(:columnheader, "Used in") + expect(rendered_component).to have_test_selector("label-row-#{label.id}") + end + end +end diff --git a/spec/components/admin/labels/row_component_spec.rb b/spec/components/admin/labels/row_component_spec.rb new file mode 100644 index 000000000000..5db226a86696 --- /dev/null +++ b/spec/components/admin/labels/row_component_spec.rb @@ -0,0 +1,82 @@ +# 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 "rails_helper" + +RSpec.describe Admin::Labels::RowComponent, type: :component do + include Rails.application.routes.url_helpers + + let(:query) { Queries::Labels::LabelQuery.new } + + subject(:rendered_component) do + with_request_url "/admin/labels" do + render_inline(Admin::Labels::TableComponent.new(rows: [label], query:)) + end + end + + context "with a used label" do + shared_let(:record) { create(:label, name: "Bug") } + shared_let(:labelings) { create_list(:labeling, 3, label: record) } + + let(:label) { Label.with_usage_count.find(record.id) } + + it "renders the name as a chip" do + expect(rendered_component).to have_test_selector("label-name", text: "Bug") + end + + it "renders the pluralised usage count" do + expect(rendered_component).to have_test_selector("label-usage", text: "3 work packages") + end + end + + context "with an unused label" do + shared_let(:record) { create(:label, name: "Feature") } + + let(:label) { Label.with_usage_count.find(record.id) } + + it "renders a dash instead of a count" do + expect(rendered_component).to have_test_selector("label-usage", text: "-") + end + end + + context "with the actions menu" do + shared_let(:record) { create(:label, name: "Bug") } + + let(:label) { Label.with_usage_count.find(record.id) } + + it "links Rename to the edit dialog" do + expect(rendered_component).to have_selector(:async_dialog_trigger, "Rename", href: edit_dialog_admin_label_path(label)) + end + + it "links Delete to the deletion dialog" do + expect(rendered_component).to have_selector(:async_dialog_trigger, "Delete", href: deletion_dialog_admin_label_path(label)) + end + end +end diff --git a/spec/components/admin/labels/sub_header_component_spec.rb b/spec/components/admin/labels/sub_header_component_spec.rb new file mode 100644 index 000000000000..50661c6ae5d4 --- /dev/null +++ b/spec/components/admin/labels/sub_header_component_spec.rb @@ -0,0 +1,76 @@ +# 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 "rails_helper" + +RSpec.describe Admin::Labels::SubHeaderComponent, type: :component do + include Rails.application.routes.url_helpers + + subject(:rendered_component) do + with_request_url "/admin/labels" do + render_inline(described_class.new(query:)) + end + end + + let(:query) { Queries::Labels::LabelQuery.new } + + it "searches against the labels search endpoint" do + rendered_component + + header = find_test_selector("labels-sub-header", visible: :all) + expect(header["data-filter--filters-form-url-path-name-value"]).to eq(search_admin_labels_path) + end + + it "renders the search input without a value when no filter is active" do + rendered_component + + expect(page.find_field("Search", visible: :all).value).to be_blank + end + + it "prefills the search input from an active name filter" do + query.where(:name, "~", ["urgent"]) + + rendered_component + + expect(page).to have_field("Search", with: "urgent", visible: :all) + end + + it "renders the add label button pointing at the create dialog" do + rendered_component + + expect(page).to have_selector(:async_dialog_trigger, "Label", href: new_dialog_admin_labels_path) + end + + it "gives the add label button an aria-label distinct from its visible text" do + rendered_component + + expect(page).to have_selector(:async_dialog_trigger, "Add label") + end +end diff --git a/spec/components/admin/labels/table_component_spec.rb b/spec/components/admin/labels/table_component_spec.rb new file mode 100644 index 000000000000..b9bd917bab9f --- /dev/null +++ b/spec/components/admin/labels/table_component_spec.rb @@ -0,0 +1,107 @@ +# 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 "rails_helper" + +RSpec.describe Admin::Labels::TableComponent, type: :component do + include Rails.application.routes.url_helpers + + shared_let(:admin) { create(:admin) } + + let(:query) { Queries::Labels::LabelQuery.new } + + subject(:rendered_component) do + with_request_url "/admin/labels" do + render_inline(described_class.new(rows:, query:)) + end + end + + context "with labels" do + shared_let(:used) { create(:label, name: "Bug") } + shared_let(:unused) { create(:label, name: "Feature") } + shared_let(:labelings) { create_list(:labeling, 2, label: used) } + + let(:rows) { Label.with_usage_count.order(:name).paginate(page: 1, per_page: 10) } + + it_behaves_like "rendering Border Box Grid heading", text: "Label" + it_behaves_like "rendering Border Box Grid heading", text: "Used in" + it_behaves_like "rendering Border Box Grid mobile heading", text: "Labels" + it_behaves_like "rendering Border Box Grid rows", row_count: 2, col_count: 2 + + it "renders one row per label" do + expect(rendered_component).to have_test_selector("label-row-#{used.id}") + expect(rendered_component).to have_test_selector("label-row-#{unused.id}") + end + + it "renders the usage count for a used label and a dash for an unused one" do + rendered_component + + used_row = find_test_selector("label-row-#{used.id}") + expect(used_row).to have_test_selector("label-usage", text: "2 work packages") + + unused_row = find_test_selector("label-row-#{unused.id}") + expect(unused_row).to have_test_selector("label-usage", text: "-") + end + + it "renders a pagination footer" do + expect(rendered_component).to have_css(".op-pagination") + end + end + + context "without labels" do + let(:rows) { Label.with_usage_count.paginate(page: 1, per_page: 10) } + + it "keeps the column headers above the blank slate" do + expect(rendered_component).to have_selector(:columnheader, "Used in") + end + + context "with no active filter" do + it_behaves_like "rendering Blank Slate", + heading: I18n.t("admin.labels.table_component.blank_slate.title"), + icon: :tag + end + + context "with a name filter matching nothing" do + let(:query) do + ParamsToQueryService + .new(Label, admin, query_class: Queries::Labels::LabelQuery) + .call(ActionController::Parameters.new(filters: [{ name: { operator: "~", values: ["zzz"] } }].to_json)) + end + + it_behaves_like "rendering Blank Slate", + heading: I18n.t("admin.labels.table_component.no_matches.title"), + icon: :search + + it "renders the no-matches description" do + expect(rendered_component).to have_text(I18n.t("admin.labels.table_component.no_matches.description")) + end + end + end +end diff --git a/spec/features/admin/labels/manage_spec.rb b/spec/features/admin/labels/manage_spec.rb new file mode 100644 index 000000000000..edb167157853 --- /dev/null +++ b/spec/features/admin/labels/manage_spec.rb @@ -0,0 +1,160 @@ +# 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 "Managing labels", :js, with_flag: { work_package_labels: true } do + shared_let(:admin) { create(:admin) } + + current_user { admin } + + def row_selector(label) + "label-row-#{label.id}" + end + + def within_row_menu(label, &) + within_test_selector(row_selector(label)) do + find_test_selector("label-row-menu").click_link_or_button + within("anchored-position", &) + end + end + + def expect_row(label, usage:) + within_test_selector(row_selector(label)) do + expect(page).to have_test_selector("label-name", text: label.name) + expect(page).to have_test_selector("label-usage", text: usage) + end + end + + it "shows the menu entry and a blank slate" do + visit admin_index_path + + within("#menu-sidebar") do + expect(page).to have_link("Labels", href: %r{/admin/labels\z}) + click_link "Labels" + end + + expect(page).to have_css(".PageHeader-title", text: "Labels") + expect(page).to have_css(".blankslate", text: "No labels yet") + end + + it "creates a label" do + visit admin_labels_path + + find_test_selector("add-label-button").click + page.within_modal("Create label") do + fill_in "Name", with: "Machine Learning" + click_on "Create" + end + + expect_flash(message: "Successful creation.") + label = Label.find_by!(name: "Machine Learning") + expect_row(label, usage: "-") + end + + it "renames a label, rejecting a case-insensitive duplicate first" do + label = create(:label, name: "Machine Learning") + create(:label, name: "Neural learning") + + visit admin_labels_path + + within_row_menu(label) { click_on "Rename" } + page.within_modal('Rename "Machine Learning"') do + fill_in "Name", with: "neural learning" + wait_for_turbo_stream { click_on "Rename" } + expect(page).to have_text("A label with this name already exists. Please use another one.") + + fill_in "Name", with: "Deep learning" + click_on "Rename" + end + + expect_flash(message: "Successful update.") + expect_row(label.reload, usage: "-") + expect(label.name).to eq("Deep learning") + end + + it "deletes a label along with its labelings" do + label = create(:label, name: "Deprecated") + create_list(:labeling, 2, label:) + + visit admin_labels_path + + expect_row(label, usage: "2 work packages") + + within_row_menu(label) { click_on "Delete" } + page.within_modal("Delete this label?") do + check "I understand that this action is not reversible" + click_on "Delete" + end + + expect_flash(message: "Successful deletion.") + expect(page).to have_no_test_selector(row_selector(label)) + expect(Label.where(id: label.id)).not_to exist + expect(Labeling.where(label_id: label.id)).not_to exist + end + + it "searches the label list and paginates the results", + with_settings: { per_page_options: "2 5 10" } do + alpha = create(:label, name: "Alpha") + beta = create(:label, name: "Beta") + gamma = create(:label, name: "Gamma") + + visit admin_labels_path + + expect(page).to have_test_selector(row_selector(alpha)) + expect(page).to have_test_selector(row_selector(beta)) + expect(page).to have_no_test_selector(row_selector(gamma)) + expect(page).to have_css(".op-pagination") + + fill_in "Search", with: "gam" + wait_for_network_idle + expect(page).to have_test_selector(row_selector(gamma)) + expect(page).to have_no_test_selector(row_selector(alpha)) + + fill_in "Search", with: "zzz" + wait_for_network_idle + expect(page).to have_css(".blankslate", text: "No labels match your search") + + find_by_id("labels-filters-form-clear-button").click + wait_for_network_idle + expect(page).to have_test_selector(row_selector(alpha)) + expect(page).to have_test_selector(row_selector(beta)) + end + + it "hides the menu entry and the page when the feature flag is inactive", + with_flag: { work_package_labels: false } do + visit admin_index_path + + within("#menu-sidebar") { expect(page).to have_no_link("Labels") } + + visit admin_labels_path + expect(page).to have_text("[Error 404]") + end +end diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb index 382c4e6a38ed..87db8b204fcd 100644 --- a/spec/models/label_spec.rb +++ b/spec/models/label_spec.rb @@ -74,6 +74,23 @@ end end + describe ".page_of" do + let!(:alpha) { create(:label, name: "Alpha") } + let!(:bravo) { create(:label, name: "bravo") } + let!(:charlie) { create(:label, name: "Charlie") } + let!(:zulu) { create(:label, name: "Zulu") } + + it "returns the 1-based page the label falls on when ordered case-insensitively by name" do + expect(described_class.page_of(alpha, per_page: 2)).to eq(1) + expect(described_class.page_of(charlie, per_page: 2)).to eq(2) + expect(described_class.page_of(zulu, per_page: 2)).to eq(2) + end + + it "sorts case-insensitively, placing bravo between alpha and charlie" do + expect(described_class.page_of(bravo, per_page: 1)).to eq(2) + end + end + describe "#destroy" do it "removes its labelings" do label = create(:label) diff --git a/spec/requests/admin/labels_spec.rb b/spec/requests/admin/labels_spec.rb new file mode 100644 index 000000000000..6ce51c43ef91 --- /dev/null +++ b/spec/requests/admin/labels_spec.rb @@ -0,0 +1,143 @@ +# 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 "Admin labels", :skip_csrf, + type: :rails_request, with_flag: { work_package_labels: true } do + shared_let(:admin) { create(:admin) } + + let(:turbo_stream_headers) { { "Accept" => "text/vnd.turbo-stream.html" } } + + current_user { admin } + + def sidebar_menu_links(body) + Nokogiri::HTML5(body).css("#menu-sidebar a[href]").map { URI(it["href"]).path } + end + + it "renders the admin menu entry" do + get admin_index_path + + expect(sidebar_menu_links(response.body)).to include(admin_labels_path) + end + + describe "GET /admin/labels/search", with_settings: { per_page_options: "1 5 10" } do + before { create_list(:label, 2) } + + it "renders pagination links that target the index action, not the search action" do + get search_admin_labels_path, params: { per_page: 1 }, headers: turbo_stream_headers + + expect(response.body).to include("#{admin_labels_path}?") + expect(response.body).not_to include("#{search_admin_labels_path}?") + end + end + + describe "POST /admin/labels" do + it "redirects to the page containing the new label in the default alphabetical order", + with_settings: { per_page_options: "2 5 10" } do + create(:label, name: "Alpha") + create(:label, name: "Bravo") + create(:label, name: "Charlie") + + post admin_labels_path, params: { label: { name: "Zulu" }, per_page: 2 } + + expect(response).to redirect_to(admin_labels_path(page: 2)) + expect(Label.find_by!(name: "Zulu").author).to eq(admin) + end + end + + describe "PATCH /admin/labels/:id" do + let!(:label) { create(:label, name: "Bug") } + + it "redirects to the page the renamed label sorts on", + with_settings: { per_page_options: "2 5 10" } do + create(:label, name: "Alpha") + create(:label, name: "Charlie") + + patch admin_label_path(label), params: { label: { name: "Zulu" }, per_page: 2 } + + expect(response).to redirect_to(admin_labels_path(page: 2)) + end + end + + describe "DELETE /admin/labels/:id" do + let!(:label) { create(:label) } + + it "keeps the label and flashes the error when the delete service fails" do + errors = ActiveModel::Errors.new(label).tap { |e| e.add(:base, "cannot be deleted right now") } + service = instance_double(Labels::DeleteService, call: ServiceResult.failure(errors:)) + allow(Labels::DeleteService).to receive(:new).and_return(service) + + delete admin_label_path(label) + + expect(response).to redirect_to(admin_labels_path) + expect(flash[:error]).to include("cannot be deleted right now") + expect(Label.where(id: label.id)).to exist + end + end + + context "when not an admin" do + let!(:label) { create(:label) } + + current_user { create(:user) } + + it "forbids listing" do + get admin_labels_path + expect(response).to have_http_status(:forbidden) + end + + it "forbids creating a label" do + post admin_labels_path, params: { label: { name: "Nope" } } + expect(response).to have_http_status(:forbidden) + expect(Label.where(name: "Nope")).not_to exist + end + end + + context "when the feature flag is inactive", with_flag: { work_package_labels: false } do + let!(:label) { create(:label) } + + it "responds with 404 for create and creates nothing" do + post admin_labels_path, params: { label: { name: "Nope" } } + expect(response).to have_http_status(:not_found) + expect(Label.where(name: "Nope")).not_to exist + end + + it "responds with 404 for a member route" do + get deletion_dialog_admin_label_path(label) + expect(response).to have_http_status(:not_found) + end + + it "does not render the admin menu entry" do + get admin_index_path + + expect(sidebar_menu_links(response.body)).not_to include(admin_labels_path) + end + end +end diff --git a/spec/support/capybara/async_dialog_selector.rb b/spec/support/capybara/async_dialog_selector.rb new file mode 100644 index 000000000000..192454c6a96f --- /dev/null +++ b/spec/support/capybara/async_dialog_selector.rb @@ -0,0 +1,89 @@ +# 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. +#++ + +# Finds links and buttons that open a dialog through the `async-dialog` +# Stimulus controller, matched by visible text or `aria-label`. +# +# @example Assert a menu item opens a dialog for a given endpoint +# expect(page).to have_selector(:async_dialog_trigger, "Delete", href: deletion_dialog_admin_label_path(label)) +# +# @example Shorthand matcher +# expect(page).to have_async_dialog_trigger("Label", href: new_dialog_admin_labels_path) +# +# @example Click the trigger +# find(:async_dialog_trigger, "Rename").click +# +# Filters: `href:` (exact match), plus `aria:` and `described_by:` from +# capybara_accessible_selectors. + +Capybara.add_selector(:async_dialog_trigger, locator_type: [String, Symbol]) do + label "async dialog trigger" + + xpath do |locator, **| + xpath = XPath.descendant(:a, :button)[XPath.attr(:"data-controller").contains_word("async-dialog")] + + unless locator.nil? + locator = locator.to_s + matchers = [ + XPath.string.n.is(locator), + XPath.attr(:"aria-label").is(locator) + ] + xpath = xpath[matchers.reduce(:|)] + end + + xpath + end + + node_filter(:href) do |node, href| + (node[:href] == href).tap do |res| + add_error("Expected href to be #{href.inspect} but it was #{node[:href].inspect}") unless res + end + end + + describe_expression_filters do |href: nil, **| + " with href #{href.inspect}" if href + end + + filter_set(:capybara_accessible_selectors, %i[aria described_by]) +end + +module Capybara + module RSpecMatchers + # Shorthand for `have_selector(:async_dialog_trigger, ...)`; see the examples above. + def have_async_dialog_trigger(locator = nil, **, &) + Matchers::HaveSelector.new(:async_dialog_trigger, locator, **, &) + end + + # Shorthand for `have_no_selector(:async_dialog_trigger, ...)`. + def have_no_async_dialog_trigger(...) + Matchers::NegatedMatcher.new(have_async_dialog_trigger(...)) + end + end +end diff --git a/spec/support/finders/test_selector_finders.rb b/spec/support/finders/test_selector_finders.rb index afd9184badb7..e58166251c67 100644 --- a/spec/support/finders/test_selector_finders.rb +++ b/spec/support/finders/test_selector_finders.rb @@ -66,4 +66,5 @@ def have_no_test_selector(value, **) Capybara::DSL.extend(TestSelectorFinders) config.include TestSelectorFinders, type: :feature config.include TestSelectorFinders, type: :component + config.include TestSelectorFinders, type: :rails_request end