From c00807606fbed9d39696e8172dc88ff83a1e28ae Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 16 Sep 2026 20:20:10 +0300 Subject: [PATCH 01/22] Add the labels administration page Admins get a paginated, searchable list of all labels with their usage and can create, rename and delete labels from dialogs. The page sits behind the work_package_labels flag and is admin-only. --- app/components/_index.sass | 1 + .../labels/delete_dialog_component.html.erb | 47 ++++ .../admin/labels/delete_dialog_component.rb | 48 ++++ .../admin/labels/dialog_component.html.erb | 60 +++++ .../admin/labels/dialog_component.rb | 65 +++++ .../admin/labels/form_component.html.erb | 43 ++++ app/components/admin/labels/form_component.rb | 63 +++++ .../admin/labels/list_component.html.erb | 70 +++++ app/components/admin/labels/list_component.rb | 46 ++++ .../admin/labels/list_component.sass | 18 ++ .../admin/labels/row_component.html.erb | 67 +++++ app/components/admin/labels/row_component.rb | 48 ++++ .../labels/sub_header_component.html.erb | 56 ++++ .../admin/labels/sub_header_component.rb | 69 +++++ app/controllers/admin/labels_controller.rb | 133 ++++++++++ .../concerns/labels/labels_feature.rb | 41 +++ app/forms/admin/labels/form.rb | 44 ++++ app/views/admin/labels/index.html.erb | 45 ++++ config/initializers/menus.rb | 7 + config/locales/en.yml | 35 +++ config/routes.rb | 11 + .../labels/delete_dialog_component_spec.rb | 76 ++++++ .../admin/labels/list_component_spec.rb | 112 ++++++++ .../admin/labels/row_component_spec.rb | 98 +++++++ .../admin/labels/sub_header_component_spec.rb | 80 ++++++ spec/requests/admin/labels_spec.rb | 243 ++++++++++++++++++ 26 files changed, 1626 insertions(+) create mode 100644 app/components/admin/labels/delete_dialog_component.html.erb create mode 100644 app/components/admin/labels/delete_dialog_component.rb create mode 100644 app/components/admin/labels/dialog_component.html.erb create mode 100644 app/components/admin/labels/dialog_component.rb create mode 100644 app/components/admin/labels/form_component.html.erb create mode 100644 app/components/admin/labels/form_component.rb create mode 100644 app/components/admin/labels/list_component.html.erb create mode 100644 app/components/admin/labels/list_component.rb create mode 100644 app/components/admin/labels/list_component.sass create mode 100644 app/components/admin/labels/row_component.html.erb create mode 100644 app/components/admin/labels/row_component.rb create mode 100644 app/components/admin/labels/sub_header_component.html.erb create mode 100644 app/components/admin/labels/sub_header_component.rb create mode 100644 app/controllers/admin/labels_controller.rb create mode 100644 app/controllers/concerns/labels/labels_feature.rb create mode 100644 app/forms/admin/labels/form.rb create mode 100644 app/views/admin/labels/index.html.erb create mode 100644 spec/components/admin/labels/delete_dialog_component_spec.rb create mode 100644 spec/components/admin/labels/list_component_spec.rb create mode 100644 spec/components/admin/labels/row_component_spec.rb create mode 100644 spec/components/admin/labels/sub_header_component_spec.rb create mode 100644 spec/requests/admin/labels_spec.rb diff --git a/app/components/_index.sass b/app/components/_index.sass index c1e5671c5404..443103e86c42 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -1,3 +1,4 @@ +@import "admin/labels/list_component" @import "colors/swatch_component" @import "enterprise_edition/banner_component" @import "op_primer/border_box_table_component" 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..85553c805e4c --- /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", 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..f90401641e19 --- /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:, state:)) + 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..5d71cdaaf292 --- /dev/null +++ b/app/components/admin/labels/dialog_component.rb @@ -0,0 +1,65 @@ +# 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 + include Primer::FetchOrFallbackHelper + + DIALOG_ID = "admin-label-dialog" + + STATE_DEFAULT = :create + STATE_OPTIONS = [STATE_DEFAULT, :rename].freeze + + attr_reader :label, :state + + delegate :create?, :rename?, to: :state + + def initialize(label:, state: STATE_DEFAULT) + super() + + @label = label + @state = ActiveSupport::StringInquirer.new(fetch_or_fallback(STATE_OPTIONS, state, STATE_DEFAULT).to_s) + end + + private + + def title + create? ? t(".create_title") : t(".rename_title", name: label.name) + end + + def button_caption + create? ? t(:button_create) : t(:button_rename) + 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..b735e91183d2 --- /dev/null +++ b/app/components/admin/labels/form_component.rb @@ -0,0 +1,63 @@ +# 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" + + attr_reader :state + + delegate :create?, to: :state + + # `model` (not `label`) is deliberate: this component is the Rails form + # template context for its nested primer_form_with block, and a reader + # named `label` would shadow ActionView::Helpers::FormHelper#label. + def initialize(label:, state:) + super(label) + + @state = ActiveSupport::StringInquirer.new(state.to_s) + end + + private + + def http_method + create? ? :post : :patch + end + + def form_url + create? ? admin_labels_path : admin_label_path(model) + 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..9bfea47280d0 --- /dev/null +++ b/app/components/admin/labels/list_component.html.erb @@ -0,0 +1,70 @@ +<%#-- 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 + if labels.any? + render(border_box_container) do |component| + component.with_header(color: :muted) do + grid_layout("op-labels-list--header", tag: :div, align_items: :center) do |grid| + grid.with_area(:name, tag: :div, mr: 3) do + render(Primer::Beta::Text.new(font_weight: :semibold)) { t(".header.name") } + end + + grid.with_area(:usage, tag: :div, color: :muted, hide: :sm) do + render(Primer::Beta::Text.new(font_weight: :semibold)) { t(".header.usage") } + end + end + end + + labels.each do |label| + component.with_row(scheme: :default, id: helpers.dom_id(label), test_selector: "label-row-#{label.id}") do + render(Admin::Labels::RowComponent.new(label)) + end + end + + component.with_row(py: 0) do + helpers.pagination_links_full(labels, params: { action: "index" }) + end + end + elsif filtered? + render(Primer::Beta::Blankslate.new(border: true, test_selector: "labels-blank-slate")) do |component| + component.with_visual_icon(icon: :search) + component.with_heading(tag: :h2).with_content(t(".no_matches.title")) + component.with_description { t(".no_matches.description") } + end + else + render(Primer::Beta::Blankslate.new(border: true, test_selector: "labels-blank-slate")) do |component| + component.with_visual_icon(icon: :tag) + component.with_heading(tag: :h2).with_content(t(".blank_slate.title")) + component.with_description { t(".blank_slate.description") } + end + end + 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..6c3d93facb70 --- /dev/null +++ b/app/components/admin/labels/list_component.rb @@ -0,0 +1,46 @@ +# 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 + + def filtered? + query.find_active_filter(:name).present? + end + end + end +end diff --git a/app/components/admin/labels/list_component.sass b/app/components/admin/labels/list_component.sass new file mode 100644 index 000000000000..4dbf146b501f --- /dev/null +++ b/app/components/admin/labels/list_component.sass @@ -0,0 +1,18 @@ +.op-labels-list--header, +.op-labels-list--row + display: grid + grid-template-columns: 2fr 1fr auto + grid-template-areas: "name usage actions" + +.op-labels-list--header + grid-template-areas: "name usage ." + +@media screen and (max-width: $breakpoint-sm) + .op-labels-list--row + grid-template-columns: 1fr + grid-template-areas: "name" "usage" "actions" + grid-row-gap: 5px + + .op-labels-list--header + grid-template-columns: 1fr + grid-template-areas: "name" diff --git a/app/components/admin/labels/row_component.html.erb b/app/components/admin/labels/row_component.html.erb new file mode 100644 index 000000000000..8de4089131fb --- /dev/null +++ b/app/components/admin/labels/row_component.html.erb @@ -0,0 +1,67 @@ +<%#-- 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. + +++#%> + +<%= + grid_layout("op-labels-list--row", tag: :div, align_items: :center) do |grid| + grid.with_area(:name, tag: :div, mr: 3) do + render(Primer::Beta::Label.new(scheme: :secondary, test_selector: "label-name")) { label.name } + end + + grid.with_area(:usage, tag: :div, mr: 3) do + render(Primer::Beta::Text.new(color: :subtle, test_selector: "label-usage")) { usage_text } + end + + grid.with_area(:actions, tag: :div) do + 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_divider + + 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 +%> diff --git a/app/components/admin/labels/row_component.rb b/app/components/admin/labels/row_component.rb new file mode 100644 index 000000000000..e42bf040cceb --- /dev/null +++ b/app/components/admin/labels/row_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 RowComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + alias_method :label, :model + + 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 + 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/controllers/admin/labels_controller.rb b/app/controllers/admin/labels_controller.rb new file mode 100644 index 000000000000..c75abb6a6db9 --- /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, state: :create) + end + + def edit_dialog + respond_with_dialog Admin::Labels::DialogComponent.new(label: @label, state: :rename) + end + + def create + result = ::Labels::CreateService + .new(user: current_user) + .call(label_params) + + result.on_success do + flash[:notice] = t(:notice_successful_create) + redirect_to admin_labels_path + end + + result.on_failure do + update_via_turbo_stream( + component: Admin::Labels::FormComponent.new(label: result.result, state: :create), + 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 do + flash[:notice] = t(:notice_successful_update) + redirect_to admin_labels_path + end + + result.on_failure do + update_via_turbo_stream( + component: Admin::Labels::FormComponent.new(label: result.result, state: :rename), + 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 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/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 d8f7bcf1ea6c..489971520c38 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -705,6 +705,10 @@ 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: + taken: "A label with this name already exists. Please use another one." ldap_auth_source: attributes: tls_certificate_string: @@ -1752,6 +1756,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: '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." + list_component: + blank_slate: + description: "Labels created by users on work packages will appear here." + title: "No labels yet" + header: + name: "Label" + usage: "Used in" + no_matches: + description: "Try a different search term." + title: "No labels match your search" + 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" + 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." @@ -3781,6 +3815,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 2c1add2cb7f6..c826a4bebb0b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1033,6 +1033,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..11a0def62f17 --- /dev/null +++ b/spec/components/admin/labels/delete_dialog_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::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 description with the label's name" 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.') + 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") + 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..106ff0accfb8 --- /dev/null +++ b/spec/components/admin/labels/list_component_spec.rb @@ -0,0 +1,112 @@ +# 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 + + shared_let(:admin) { create(:admin) } + + subject(:rendered_component) do + with_request_url "/admin/labels" do + render_inline(described_class.new(labels, 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(:labels) { Label.with_usage_count.order(:name).paginate(page: 1, per_page: 10) } + let(:query) { Queries::Labels::LabelQuery.new } + + it "renders the column captions" do + rendered_component + + expect(page).to have_css(".Box-header", text: "Label") + expect(page).to have_css(".Box-header", text: "Used in") + end + + it "renders one row per label" do + rendered_component + + expect(page).to have_css("[data-test-selector='label-row-#{used.id}']") + expect(page).to have_css("[data-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 = page.find("[data-test-selector='label-row-#{used.id}']") + expect(used_row).to have_css("[data-test-selector='label-usage']", text: "2 work packages") + + unused_row = page.find("[data-test-selector='label-row-#{unused.id}']") + expect(unused_row).to have_css("[data-test-selector='label-usage']", text: "-") + end + + it "renders a pagination footer" do + rendered_component + + expect(page).to have_css(".op-pagination") + end + end + + context "without labels" do + let(:labels) { Label.with_usage_count.paginate(page: 1, per_page: 10) } + + context "with no active filter" do + let(:query) { Queries::Labels::LabelQuery.new } + + it_behaves_like "rendering Blank Slate", + heading: I18n.t("admin.labels.list_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.list_component.no_matches.title"), + icon: :search + + it "renders the no-matches description" do + rendered_component + + expect(page).to have_text(I18n.t("admin.labels.list_component.no_matches.description")) + end + 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..45178242ba59 --- /dev/null +++ b/spec/components/admin/labels/row_component_spec.rb @@ -0,0 +1,98 @@ +# 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 + + subject(:rendered_component) do + with_request_url "/admin/labels" do + render_inline(described_class.new(label)) + 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 + rendered_component + + expect(page).to have_css("[data-test-selector='label-name']", text: "Bug") + end + + it "renders the pluralised usage count" do + rendered_component + + expect(page).to have_css("[data-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 + rendered_component + + expect(page).to have_css("[data-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 as an async-dialog request" do + rendered_component + + expect(page).to have_css( + "a[href='#{edit_dialog_admin_label_path(label)}'][data-controller='async-dialog']", + text: "Rename", + visible: :all + ) + end + + it "links Delete to the deletion dialog as a danger, async-dialog request" do + rendered_component + + expect(page).to have_css( + "a[href='#{deletion_dialog_admin_label_path(label)}'][data-controller='async-dialog']", + text: "Delete", + visible: :all + ) + 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..41fe838300a2 --- /dev/null +++ b/spec/components/admin/labels/sub_header_component_spec.rb @@ -0,0 +1,80 @@ +# 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 "wires the filters-form controller to the search endpoint" do + rendered_component + + header = page.find("[data-test-selector='labels-sub-header']", visible: :all) + expect(header["data-controller"]).to eq("filter--filters-form") + 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_css( + "a[href='#{new_dialog_admin_labels_path}'][data-controller='async-dialog']", + text: "Label" + ) + end + + it "gives the add label button an aria-label distinct from its visible text" do + rendered_component + + expect(page).to have_css("a[aria-label='Add label']", text: "Label") + end +end diff --git a/spec/requests/admin/labels_spec.rb b/spec/requests/admin/labels_spec.rb new file mode 100644 index 000000000000..594099aa63bb --- /dev/null +++ b/spec/requests/admin/labels_spec.rb @@ -0,0 +1,243 @@ +# 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 } + + describe "GET /admin/labels" do + context "with labels" do + let!(:used) { create(:label, name: "Bug") } + let!(:unused) { create(:label, name: "Feature") } + + before { create_list(:labeling, 2, label: used) } + + it "lists the labels with their usage count" do + get admin_labels_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Bug") + expect(response.body).to include("Feature") + expect(response.body).to include("2 work packages") + end + + it "renders a dash for a label without any usage" do + get admin_labels_path + + row = Nokogiri::HTML5.fragment(response.body).at_css("[data-test-selector='label-row-#{unused.id}']") + expect(row.at_css("[data-test-selector='label-usage']").text.strip).to eq("-") + end + end + + context "without any labels" do + it "renders the blank slate" do + get admin_labels_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include("No labels yet") + end + end + + it "renders the admin menu entry" do + get admin_labels_path + + expect(response.body).to include("Labels") + end + end + + describe "GET /admin/labels/search", with_settings: { per_page_options: "1 5 10" } do + let!(:matching) { create(:label, name: "urgent-bug") } + let!(:other) { create(:label, name: "docs-update") } + + it "returns a turbo_stream response" do + get search_admin_labels_path, params: { per_page: 1 }, headers: turbo_stream_headers + + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq("text/vnd.turbo-stream.html") + end + + it "narrows the list to labels matching the name filter" do + filters = [{ name: { operator: "~", values: ["urgent"] } }].to_json + + get search_admin_labels_path, params: { filters: }, headers: turbo_stream_headers + + expect(response.body).to include(%(data-test-selector="label-row-#{matching.id}")) + expect(response.body).not_to include(%(data-test-selector="label-row-#{other.id}")) + end + + 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).not_to include("#{search_admin_labels_path}?") + end + end + + describe "GET /admin/labels/new_dialog" do + it "renders the create dialog" do + get new_dialog_admin_labels_path, headers: turbo_stream_headers + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Create label") + end + end + + describe "GET /admin/labels/:id/edit_dialog" do + let!(:label) { create(:label, name: "Machine Learning") } + + it "renders the rename dialog prefilled with the current name" do + get edit_dialog_admin_label_path(label), headers: turbo_stream_headers + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Rename "Machine Learning"").or include("Rename \"Machine Learning\"") + end + end + + describe "GET /admin/labels/:id/deletion_dialog" do + let!(:label) { create(:label, name: "Machine Learning") } + + it "renders the danger dialog" do + get deletion_dialog_admin_label_path(label), headers: turbo_stream_headers + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Delete this label?") + expect(response.body).to include("Machine Learning") + end + end + + describe "POST /admin/labels" do + it "creates the label authored by the current user and redirects to the index" do + post admin_labels_path, params: { label: { name: "Regression" } } + + expect(response).to redirect_to(admin_labels_path) + + label = Label.find_by!(name: "Regression") + expect(label.author).to eq(admin) + end + + it "rejects a blank name" do + post admin_labels_path, params: { label: { name: "" } }, headers: turbo_stream_headers + + expect(response).to have_http_status(:unprocessable_entity) + expect(Label.count).to eq(0) + end + + it "rejects a case-insensitive duplicate name" do + create(:label, name: "Bug") + + post admin_labels_path, params: { label: { name: "BUG" } }, headers: turbo_stream_headers + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include("A label with this name already exists. Please use another one.") + expect(Label.where(name: "BUG")).not_to exist + end + end + + describe "PATCH /admin/labels/:id" do + let!(:label) { create(:label, name: "Bug") } + + it "renames the label and redirects to the index" do + patch admin_label_path(label), params: { label: { name: "Defect" } } + + expect(response).to redirect_to(admin_labels_path) + expect(label.reload.name).to eq("Defect") + end + + it "rejects a duplicate name" do + create(:label, name: "Feature") + + patch admin_label_path(label), params: { label: { name: "Feature" } }, headers: turbo_stream_headers + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include("A label with this name already exists. Please use another one.") + expect(label.reload.name).to eq("Bug") + end + end + + describe "DELETE /admin/labels/:id" do + let!(:label) { create(:label) } + let!(:labeling) { create(:labeling, label:) } + + it "removes the label and its labelings" do + delete admin_label_path(label) + + expect(response).to redirect_to(admin_labels_path) + expect(Label.where(id: label.id)).not_to exist + expect(Labeling.where(id: labeling.id)).not_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 the list" do + get admin_labels_path + expect(response).to have_http_status(:not_found) + end + + 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(response.body).not_to include(">Labels<") + end + end +end From e1dab49fbe55a7c4cd4f9ef8c3a938de5bfafd5e Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 16 Sep 2026 21:10:08 +0300 Subject: [PATCH 02/22] Align the labels administration with the epic The list is a BorderBox table as the FND-5 epic specifies, the delete dialog names how many work packages are affected, name errors read as full sentences, and creating a label lands on the page that contains it. --- app/components/_index.sass | 1 - .../labels/delete_dialog_component.html.erb | 3 +- .../admin/labels/delete_dialog_component.rb | 4 + .../admin/labels/list_component.html.erb | 38 +------ app/components/admin/labels/list_component.rb | 4 - .../admin/labels/list_component.sass | 18 --- .../admin/labels/row_component.html.erb | 67 ------------ app/components/admin/labels/row_component.rb | 47 +++++++- .../admin/labels/table_component.rb | 71 ++++++++++++ app/controllers/admin/labels_controller.rb | 16 ++- config/locales/en.yml | 30 +++-- .../labels/delete_dialog_component_spec.rb | 20 +++- .../admin/labels/list_component_spec.rb | 75 ++----------- .../admin/labels/row_component_spec.rb | 24 ++-- .../admin/labels/table_component_spec.rb | 103 ++++++++++++++++++ spec/requests/admin/labels_spec.rb | 21 +++- 16 files changed, 307 insertions(+), 235 deletions(-) delete mode 100644 app/components/admin/labels/list_component.sass delete mode 100644 app/components/admin/labels/row_component.html.erb create mode 100644 app/components/admin/labels/table_component.rb create mode 100644 spec/components/admin/labels/table_component_spec.rb diff --git a/app/components/_index.sass b/app/components/_index.sass index 443103e86c42..c1e5671c5404 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -1,4 +1,3 @@ -@import "admin/labels/list_component" @import "colors/swatch_component" @import "enterprise_edition/banner_component" @import "op_primer/border_box_table_component" diff --git a/app/components/admin/labels/delete_dialog_component.html.erb b/app/components/admin/labels/delete_dialog_component.html.erb index 85553c805e4c..40e514942503 100644 --- a/app/components/admin/labels/delete_dialog_component.html.erb +++ b/app/components/admin/labels/delete_dialog_component.html.erb @@ -32,14 +32,13 @@ See COPYRIGHT and LICENSE files for more details. 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", name: label.name)) + message.with_description_content(t(".description", count: usage_count)) end dialog.with_confirmation_check_box_content(t(".confirmation")) diff --git a/app/components/admin/labels/delete_dialog_component.rb b/app/components/admin/labels/delete_dialog_component.rb index c12073d20b59..85becc55222c 100644 --- a/app/components/admin/labels/delete_dialog_component.rb +++ b/app/components/admin/labels/delete_dialog_component.rb @@ -43,6 +43,10 @@ def form_arguments method: :delete } end + + def usage_count + label.labelings.count + end end end end diff --git a/app/components/admin/labels/list_component.html.erb b/app/components/admin/labels/list_component.html.erb index 9bfea47280d0..5dfd7065c017 100644 --- a/app/components/admin/labels/list_component.html.erb +++ b/app/components/admin/labels/list_component.html.erb @@ -29,42 +29,6 @@ See COPYRIGHT and LICENSE files for more details. <%= component_wrapper do - if labels.any? - render(border_box_container) do |component| - component.with_header(color: :muted) do - grid_layout("op-labels-list--header", tag: :div, align_items: :center) do |grid| - grid.with_area(:name, tag: :div, mr: 3) do - render(Primer::Beta::Text.new(font_weight: :semibold)) { t(".header.name") } - end - - grid.with_area(:usage, tag: :div, color: :muted, hide: :sm) do - render(Primer::Beta::Text.new(font_weight: :semibold)) { t(".header.usage") } - end - end - end - - labels.each do |label| - component.with_row(scheme: :default, id: helpers.dom_id(label), test_selector: "label-row-#{label.id}") do - render(Admin::Labels::RowComponent.new(label)) - end - end - - component.with_row(py: 0) do - helpers.pagination_links_full(labels, params: { action: "index" }) - end - end - elsif filtered? - render(Primer::Beta::Blankslate.new(border: true, test_selector: "labels-blank-slate")) do |component| - component.with_visual_icon(icon: :search) - component.with_heading(tag: :h2).with_content(t(".no_matches.title")) - component.with_description { t(".no_matches.description") } - end - else - render(Primer::Beta::Blankslate.new(border: true, test_selector: "labels-blank-slate")) do |component| - component.with_visual_icon(icon: :tag) - component.with_heading(tag: :h2).with_content(t(".blank_slate.title")) - component.with_description { t(".blank_slate.description") } - end - end + 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 index 6c3d93facb70..4ccfc1b8b529 100644 --- a/app/components/admin/labels/list_component.rb +++ b/app/components/admin/labels/list_component.rb @@ -37,10 +37,6 @@ class ListComponent < ApplicationComponent alias_method :labels, :model options :query - - def filtered? - query.find_active_filter(:name).present? - end end end end diff --git a/app/components/admin/labels/list_component.sass b/app/components/admin/labels/list_component.sass deleted file mode 100644 index 4dbf146b501f..000000000000 --- a/app/components/admin/labels/list_component.sass +++ /dev/null @@ -1,18 +0,0 @@ -.op-labels-list--header, -.op-labels-list--row - display: grid - grid-template-columns: 2fr 1fr auto - grid-template-areas: "name usage actions" - -.op-labels-list--header - grid-template-areas: "name usage ." - -@media screen and (max-width: $breakpoint-sm) - .op-labels-list--row - grid-template-columns: 1fr - grid-template-areas: "name" "usage" "actions" - grid-row-gap: 5px - - .op-labels-list--header - grid-template-columns: 1fr - grid-template-areas: "name" diff --git a/app/components/admin/labels/row_component.html.erb b/app/components/admin/labels/row_component.html.erb deleted file mode 100644 index 8de4089131fb..000000000000 --- a/app/components/admin/labels/row_component.html.erb +++ /dev/null @@ -1,67 +0,0 @@ -<%#-- 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. - -++#%> - -<%= - grid_layout("op-labels-list--row", tag: :div, align_items: :center) do |grid| - grid.with_area(:name, tag: :div, mr: 3) do - render(Primer::Beta::Label.new(scheme: :secondary, test_selector: "label-name")) { label.name } - end - - grid.with_area(:usage, tag: :div, mr: 3) do - render(Primer::Beta::Text.new(color: :subtle, test_selector: "label-usage")) { usage_text } - end - - grid.with_area(:actions, tag: :div) do - 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_divider - - 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 -%> diff --git a/app/components/admin/labels/row_component.rb b/app/components/admin/labels/row_component.rb index e42bf040cceb..cefef5c63ac9 100644 --- a/app/components/admin/labels/row_component.rb +++ b/app/components/admin/labels/row_component.rb @@ -30,11 +30,25 @@ module Admin module Labels - class RowComponent < ApplicationComponent - include OpPrimer::ComponentHelpers - + 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")) { 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 @@ -43,6 +57,33 @@ 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_divider + + 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/table_component.rb b/app/components/admin/labels/table_component.rb new file mode 100644 index 000000000000..5ab66c2b7f40 --- /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 = I18n.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 index c75abb6a6db9..e2242be9de3b 100644 --- a/app/controllers/admin/labels_controller.rb +++ b/app/controllers/admin/labels_controller.rb @@ -69,10 +69,7 @@ def create .new(user: current_user) .call(label_params) - result.on_success do - flash[:notice] = t(:notice_successful_create) - redirect_to admin_labels_path - end + result.on_success { redirect_to_created_label(result.result) } result.on_failure do update_via_turbo_stream( @@ -126,6 +123,17 @@ def find_label @label = Label.find(params.expect(:id)) end + def redirect_to_created_label(label) + flash[:notice] = t(:notice_successful_create) + redirect_to admin_labels_path(page: page_containing(label)) + end + + def page_containing(label) + preceding_count = Label.where(Label.arel_table[:name].lower.lt(label.name.downcase)).count + page = (preceding_count / per_page_param) + 1 + page if page > 1 + end + def label_params params.expect(label: [:name]) end diff --git a/config/locales/en.yml b/config/locales/en.yml index 489971520c38..aee2d470b2e5 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -708,7 +708,10 @@ en: 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: @@ -1758,8 +1761,11 @@ en: labels: delete_dialog_component: - confirmation: "I understand that this action is not reversible" - description: 'This will remove the label "%{name}" from all work packages in all projects.' + confirmation: "I understand that this action is not reversible." + description: + one: "This will remove the label from 1 work package that currently uses it. Are you sure you want to delete it?" + other: "This will remove the label from %{count} work packages that currently use it. Are you sure you want to delete it?" + zero: "This label is not used by any work package. Are you sure you want to delete it?" heading: "Delete this label?" title: "Delete this label?" dialog_component: @@ -1767,16 +1773,6 @@ en: 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." - list_component: - blank_slate: - description: "Labels created by users on work packages will appear here." - title: "No labels yet" - header: - name: "Label" - usage: "Used in" - no_matches: - description: "Try a different search term." - title: "No labels match your search" row_component: used_in_work_packages: one: "%{count} work package" @@ -1785,6 +1781,16 @@ en: 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: diff --git a/spec/components/admin/labels/delete_dialog_component_spec.rb b/spec/components/admin/labels/delete_dialog_component_spec.rb index 11a0def62f17..d4bb847e750b 100644 --- a/spec/components/admin/labels/delete_dialog_component_spec.rb +++ b/spec/components/admin/labels/delete_dialog_component_spec.rb @@ -41,17 +41,27 @@ let(:label) { create(:label, name: "Machine Learning") } - it "renders the confirmation heading and description with the label's name" do + it "renders the confirmation heading and the no-usage description" 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_text("This label is not used by any work package. Are you sure you want to delete it?") + end + + context "with a used label" do + before { create_list(:labeling, 2, label:) } + + it "renders the pluralised usage count in the description" do + rendered_component + + expect(page).to have_text("This will remove the label from 2 work packages that currently use it.") + end 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") + 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 @@ -68,9 +78,9 @@ expect(page).to have_no_button("Delete permanently") end - it "labels the cancel button Close" do + it "labels the cancel button Cancel" do rendered_component - expect(page).to have_button("Close") + expect(page).to have_button("Cancel") end end diff --git a/spec/components/admin/labels/list_component_spec.rb b/spec/components/admin/labels/list_component_spec.rb index 106ff0accfb8..62a5e0199fc0 100644 --- a/spec/components/admin/labels/list_component_spec.rb +++ b/spec/components/admin/labels/list_component_spec.rb @@ -34,6 +34,10 @@ include Rails.application.routes.url_helpers shared_let(:admin) { create(:admin) } + shared_let(:label) { create(:label, name: "Bug") } + + 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 @@ -41,72 +45,13 @@ 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(:labels) { Label.with_usage_count.order(:name).paginate(page: 1, per_page: 10) } - let(:query) { Queries::Labels::LabelQuery.new } - - it "renders the column captions" do - rendered_component - - expect(page).to have_css(".Box-header", text: "Label") - expect(page).to have_css(".Box-header", text: "Used in") - end - - it "renders one row per label" do - rendered_component - - expect(page).to have_css("[data-test-selector='label-row-#{used.id}']") - expect(page).to have_css("[data-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 = page.find("[data-test-selector='label-row-#{used.id}']") - expect(used_row).to have_css("[data-test-selector='label-usage']", text: "2 work packages") - - unused_row = page.find("[data-test-selector='label-row-#{unused.id}']") - expect(unused_row).to have_css("[data-test-selector='label-usage']", text: "-") - end - - it "renders a pagination footer" do - rendered_component - - expect(page).to have_css(".op-pagination") - 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 "without labels" do - let(:labels) { Label.with_usage_count.paginate(page: 1, per_page: 10) } - - context "with no active filter" do - let(:query) { Queries::Labels::LabelQuery.new } - - it_behaves_like "rendering Blank Slate", - heading: I18n.t("admin.labels.list_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.list_component.no_matches.title"), - icon: :search - - it "renders the no-matches description" do - rendered_component - - expect(page).to have_text(I18n.t("admin.labels.list_component.no_matches.description")) - end - end + it "renders the table inside the wrapper" do + expect(rendered_component).to have_css(".Box") + expect(rendered_component).to have_css("[data-test-selector='label-row-#{label.id}']") end end diff --git a/spec/components/admin/labels/row_component_spec.rb b/spec/components/admin/labels/row_component_spec.rb index 45178242ba59..201290c26b8e 100644 --- a/spec/components/admin/labels/row_component_spec.rb +++ b/spec/components/admin/labels/row_component_spec.rb @@ -33,9 +33,11 @@ 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(described_class.new(label)) + render_inline(Admin::Labels::TableComponent.new(rows: [label], query:)) end end @@ -46,15 +48,11 @@ let(:label) { Label.with_usage_count.find(record.id) } it "renders the name as a chip" do - rendered_component - - expect(page).to have_css("[data-test-selector='label-name']", text: "Bug") + expect(rendered_component).to have_css("[data-test-selector='label-name']", text: "Bug") end it "renders the pluralised usage count" do - rendered_component - - expect(page).to have_css("[data-test-selector='label-usage']", text: "3 work packages") + expect(rendered_component).to have_css("[data-test-selector='label-usage']", text: "3 work packages") end end @@ -64,9 +62,7 @@ let(:label) { Label.with_usage_count.find(record.id) } it "renders a dash instead of a count" do - rendered_component - - expect(page).to have_css("[data-test-selector='label-usage']", text: "-") + expect(rendered_component).to have_css("[data-test-selector='label-usage']", text: "-") end end @@ -76,9 +72,7 @@ let(:label) { Label.with_usage_count.find(record.id) } it "links Rename to the edit dialog as an async-dialog request" do - rendered_component - - expect(page).to have_css( + expect(rendered_component).to have_css( "a[href='#{edit_dialog_admin_label_path(label)}'][data-controller='async-dialog']", text: "Rename", visible: :all @@ -86,9 +80,7 @@ end it "links Delete to the deletion dialog as a danger, async-dialog request" do - rendered_component - - expect(page).to have_css( + expect(rendered_component).to have_css( "a[href='#{deletion_dialog_admin_label_path(label)}'][data-controller='async-dialog']", text: "Delete", visible: :all 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..c02210427679 --- /dev/null +++ b/spec/components/admin/labels/table_component_spec.rb @@ -0,0 +1,103 @@ +# 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_css("[data-test-selector='label-row-#{used.id}']") + expect(rendered_component).to have_css("[data-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 = page.find("[data-test-selector='label-row-#{used.id}']") + expect(used_row).to have_css("[data-test-selector='label-usage']", text: "2 work packages") + + unused_row = page.find("[data-test-selector='label-row-#{unused.id}']") + expect(unused_row).to have_css("[data-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) } + + 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/requests/admin/labels_spec.rb b/spec/requests/admin/labels_spec.rb index 594099aa63bb..31b39479533e 100644 --- a/spec/requests/admin/labels_spec.rb +++ b/spec/requests/admin/labels_spec.rb @@ -133,7 +133,15 @@ expect(response).to have_http_status(:ok) expect(response.body).to include("Delete this label?") - expect(response.body).to include("Machine Learning") + expect(response.body).to include("This label is not used by any work package") + end + + it "reports the label's usage count in the confirmation copy" do + create_list(:labeling, 2, label:) + + get deletion_dialog_admin_label_path(label), headers: turbo_stream_headers + + expect(response.body).to include("This will remove the label from 2 work packages that currently use it") end end @@ -147,6 +155,17 @@ expect(label.author).to eq(admin) end + 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)) + end + it "rejects a blank name" do post admin_labels_path, params: { label: { name: "" } }, headers: turbo_stream_headers From bac1df3916dcaff703ee851e5c8b24d4a37a12e1 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 16 Sep 2026 21:10:11 +0300 Subject: [PATCH 03/22] Cover the labels administration with a feature spec Walks through navigation, create, rename, delete, search with pagination, and the flag-off case in the browser. --- spec/features/admin/labels/manage_spec.rb | 161 ++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 spec/features/admin/labels/manage_spec.rb diff --git a/spec/features/admin/labels/manage_spec.rb b/spec/features/admin/labels/manage_spec.rb new file mode 100644 index 000000000000..0bca90a1d4ca --- /dev/null +++ b/spec/features/admin/labels/manage_spec.rb @@ -0,0 +1,161 @@ +# 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, rejecting a blank name first" do + visit admin_labels_path + + find_test_selector("add-label-button").click + page.within_modal("Create label") do + click_on "Create" + expect(page).to have_button("Create") + expect(Label.count).to eq(0) + + 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") + + wait_for_turbo_stream { fill_in "Search", with: "gam" } + expect(page).to have_test_selector(row_selector(gamma)) + expect(page).to have_no_test_selector(row_selector(alpha)) + + wait_for_turbo_stream { fill_in "Search", with: "zzz" } + expect(page).to have_css(".blankslate", text: "No labels match your search") + + wait_for_turbo_stream { fill_in "Search", with: "" } + 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 From 4fbcd7d034ce029c62ec212552548b1bb604852a Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 16 Sep 2026 21:36:46 +0300 Subject: [PATCH 04/22] Tighten the labels administration after review Long label names truncate instead of overflowing the row, the page lookup after creating a label compares case-insensitively in SQL only, and the dialog, form and delete-failure paths gain specs. --- app/components/admin/labels/row_component.rb | 10 ++- .../admin/labels/table_component.rb | 2 +- app/controllers/admin/labels_controller.rb | 4 +- .../admin/labels/dialog_component_spec.rb | 81 +++++++++++++++++ .../admin/labels/form_component_spec.rb | 89 +++++++++++++++++++ .../admin/labels/row_component_spec.rb | 11 +++ spec/requests/admin/labels_spec.rb | 12 +++ 7 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 spec/components/admin/labels/dialog_component_spec.rb create mode 100644 spec/components/admin/labels/form_component_spec.rb diff --git a/app/components/admin/labels/row_component.rb b/app/components/admin/labels/row_component.rb index cefef5c63ac9..8aa053e3ae96 100644 --- a/app/components/admin/labels/row_component.rb +++ b/app/components/admin/labels/row_component.rb @@ -38,7 +38,15 @@ def row_data end def name - render(Primer::Beta::Label.new(scheme: :secondary, test_selector: "label-name")) { label.name } + render( + Primer::Beta::Label.new( + scheme: :secondary, + test_selector: "label-name", + classes: "ellipsis", + style: "max-width: 100%", + title: label.name + ) + ) { label.name } end def usage diff --git a/app/components/admin/labels/table_component.rb b/app/components/admin/labels/table_component.rb index 5ab66c2b7f40..bb31d19eab5f 100644 --- a/app/components/admin/labels/table_component.rb +++ b/app/components/admin/labels/table_component.rb @@ -47,7 +47,7 @@ def headers ] end - def mobile_title = I18n.t(:label_label_plural) + def mobile_title = t(:label_label_plural) def filtered? query.find_active_filter(:name).present? diff --git a/app/controllers/admin/labels_controller.rb b/app/controllers/admin/labels_controller.rb index e2242be9de3b..c8c29888da2a 100644 --- a/app/controllers/admin/labels_controller.rb +++ b/app/controllers/admin/labels_controller.rb @@ -129,7 +129,9 @@ def redirect_to_created_label(label) end def page_containing(label) - preceding_count = Label.where(Label.arel_table[:name].lower.lt(label.name.downcase)).count + name = Label.arel_table[:name] + quoted_name = Arel::Nodes::NamedFunction.new("LOWER", [Arel::Nodes.build_quoted(label.name)]) + preceding_count = Label.where(name.lower.lt(quoted_name)).count page = (preceding_count / per_page_param) + 1 page if page > 1 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..c099aacdb3d4 --- /dev/null +++ b/spec/components/admin/labels/dialog_component_spec.rb @@ -0,0 +1,81 @@ +# 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:, state:)) + end + end + + context "in the create state" do + let(:label) { Label.new } + let(:state) { :create } + + 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 "in the rename state" do + let(:label) { create(:label, name: "Machine Learning") } + let(:state) { :rename } + + 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..847c6baa90ab --- /dev/null +++ b/spec/components/admin/labels/form_component_spec.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. +#++ + +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:, state:)) + end + end + + context "for a new label" do + let(:label) { Label.new } + let(:state) { :create } + + 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") } + let(:state) { :rename } + + 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_stubbed(:label, name: "Bug") } + let(:state) { :create } + + before { label.errors.add(:name, :taken) } + + 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/row_component_spec.rb b/spec/components/admin/labels/row_component_spec.rb index 201290c26b8e..29d559ac959b 100644 --- a/spec/components/admin/labels/row_component_spec.rb +++ b/spec/components/admin/labels/row_component_spec.rb @@ -66,6 +66,17 @@ end end + context "with a long name" do + shared_let(:record) { create(:label, name: "A" * 120) } + + let(:label) { Label.with_usage_count.find(record.id) } + + it "truncates the chip and exposes the full name as a title tooltip" do + expect(rendered_component).to have_css("[data-test-selector='label-name'].ellipsis[title='#{record.name}']", + visible: :all) + end + end + context "with the actions menu" do shared_let(:record) { create(:label, name: "Bug") } diff --git a/spec/requests/admin/labels_spec.rb b/spec/requests/admin/labels_spec.rb index 31b39479533e..8ee5b47efc4d 100644 --- a/spec/requests/admin/labels_spec.rb +++ b/spec/requests/admin/labels_spec.rb @@ -216,6 +216,18 @@ expect(Label.where(id: label.id)).not_to exist expect(Labeling.where(id: labeling.id)).not_to exist end + + 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 From 5bc97fec73d05830287515507dbeeafc325406dd Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 17 Sep 2026 08:51:35 +0300 Subject: [PATCH 05/22] Wait for the network instead of a turbo event in the labels search spec Cuprite in CI did not observe the stream-rendered event within the wait window; waiting for network idle after typing is what the other admin search specs do. --- spec/features/admin/labels/manage_spec.rb | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/spec/features/admin/labels/manage_spec.rb b/spec/features/admin/labels/manage_spec.rb index 0bca90a1d4ca..6d0b1267510e 100644 --- a/spec/features/admin/labels/manage_spec.rb +++ b/spec/features/admin/labels/manage_spec.rb @@ -137,14 +137,17 @@ def expect_row(label, usage:) expect(page).to have_no_test_selector(row_selector(gamma)) expect(page).to have_css(".op-pagination") - wait_for_turbo_stream { fill_in "Search", with: "gam" } + 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)) - wait_for_turbo_stream { fill_in "Search", with: "zzz" } + fill_in "Search", with: "zzz" + wait_for_network_idle expect(page).to have_css(".blankslate", text: "No labels match your search") - wait_for_turbo_stream { fill_in "Search", with: "" } + fill_in "Search", with: "" + wait_for_network_idle expect(page).to have_test_selector(row_selector(alpha)) expect(page).to have_test_selector(row_selector(beta)) end From c61740656c27bc30310ca04f9a93fbf64863f4bf Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 17 Sep 2026 11:28:11 +0300 Subject: [PATCH 06/22] Cap the label chip width with a Primer system argument --- app/components/admin/labels/row_component.rb | 2 +- spec/components/admin/labels/row_component_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/admin/labels/row_component.rb b/app/components/admin/labels/row_component.rb index 8aa053e3ae96..67daa88f46e5 100644 --- a/app/components/admin/labels/row_component.rb +++ b/app/components/admin/labels/row_component.rb @@ -43,7 +43,7 @@ def name scheme: :secondary, test_selector: "label-name", classes: "ellipsis", - style: "max-width: 100%", + w: :fit, title: label.name ) ) { label.name } diff --git a/spec/components/admin/labels/row_component_spec.rb b/spec/components/admin/labels/row_component_spec.rb index 29d559ac959b..120241a58485 100644 --- a/spec/components/admin/labels/row_component_spec.rb +++ b/spec/components/admin/labels/row_component_spec.rb @@ -72,7 +72,7 @@ let(:label) { Label.with_usage_count.find(record.id) } it "truncates the chip and exposes the full name as a title tooltip" do - expect(rendered_component).to have_css("[data-test-selector='label-name'].ellipsis[title='#{record.name}']", + expect(rendered_component).to have_css("[data-test-selector='label-name'].ellipsis.width-fit[title='#{record.name}']", visible: :all) end end From 80ce9f7856b4de6625909f01b930e49243fbeee1 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 17 Sep 2026 11:35:44 +0300 Subject: [PATCH 07/22] Show only the blank slate when no labels are listed --- .../admin/labels/list_component.html.erb | 16 +++- .../admin/labels/table_component.rb | 18 ----- config/locales/en.yml | 13 ++-- .../admin/labels/list_component_spec.rb | 42 ++++++++++- .../admin/labels/table_component_spec.rb | 73 ++++++------------- 5 files changed, 81 insertions(+), 81 deletions(-) diff --git a/app/components/admin/labels/list_component.html.erb b/app/components/admin/labels/list_component.html.erb index 5dfd7065c017..01859245c2ec 100644 --- a/app/components/admin/labels/list_component.html.erb +++ b/app/components/admin/labels/list_component.html.erb @@ -29,6 +29,20 @@ See COPYRIGHT and LICENSE files for more details. <%= component_wrapper do - render(Admin::Labels::TableComponent.new(rows: labels, query:)) + if labels.any? + render(Admin::Labels::TableComponent.new(rows: labels)) + else + render Primer::Beta::Blankslate.new(border: true, test_selector: "labels-blank-slate") do |component| + if query.find_active_filter(:name).present? + component.with_visual_icon(icon: :search) + component.with_heading(tag: :h2).with_content(t(".no_matches.title")) + component.with_description { t(".no_matches.description") } + else + component.with_visual_icon(icon: :tag) + component.with_heading(tag: :h2).with_content(t(".blank_slate.title")) + component.with_description { t(".blank_slate.description") } + end + end + end end %> diff --git a/app/components/admin/labels/table_component.rb b/app/components/admin/labels/table_component.rb index bb31d19eab5f..f6a107b07f8b 100644 --- a/app/components/admin/labels/table_component.rb +++ b/app/components/admin/labels/table_component.rb @@ -31,8 +31,6 @@ module Admin module Labels class TableComponent < OpPrimer::BorderBoxTableComponent - options :query - columns :name, :usage main_column :name mobile_columns :name, :usage @@ -49,22 +47,6 @@ def headers 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 diff --git a/config/locales/en.yml b/config/locales/en.yml index aee2d470b2e5..a05535657da0 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1773,6 +1773,13 @@ en: 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." + list_component: + blank_slate: + description: "Labels created by users on work packages will appear here." + title: "No labels yet" + no_matches: + description: "Try a different search term." + title: "No labels match your search" row_component: used_in_work_packages: one: "%{count} work package" @@ -1782,15 +1789,9 @@ en: 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: diff --git a/spec/components/admin/labels/list_component_spec.rb b/spec/components/admin/labels/list_component_spec.rb index 62a5e0199fc0..9c20a872d807 100644 --- a/spec/components/admin/labels/list_component_spec.rb +++ b/spec/components/admin/labels/list_component_spec.rb @@ -34,7 +34,6 @@ include Rails.application.routes.url_helpers shared_let(:admin) { create(:admin) } - shared_let(:label) { create(:label, name: "Bug") } let(:labels) { Label.with_usage_count.order(:name).paginate(page: 1, per_page: 10) } let(:query) { Queries::Labels::LabelQuery.new } @@ -50,8 +49,43 @@ expect(rendered_component).to have_css("##{wrapper_id}") end - it "renders the table inside the wrapper" do - expect(rendered_component).to have_css(".Box") - expect(rendered_component).to have_css("[data-test-selector='label-row-#{label.id}']") + context "with labels" do + shared_let(:label) { create(:label, name: "Bug") } + + it "renders the table inside the wrapper" do + expect(rendered_component).to have_css(".Box") + expect(rendered_component).to have_css("[data-test-selector='label-row-#{label.id}']") + end + + it "renders the column headers" do + expect(rendered_component).to have_css("[role='columnheader']", text: "Used in") + end + end + + context "without labels" do + it "renders the blank slate instead of the table header" do + expect(rendered_component).to have_test_selector("labels-blank-slate") + expect(rendered_component).to have_no_css("[role='columnheader']") + end + + context "with no active filter" do + it "shows the empty-state copy" do + expect(rendered_component).to have_heading("No labels yet", class: "blankslate-heading") + expect(rendered_component).to have_octicon(:tag) + end + 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 "shows the no-matches copy" do + expect(rendered_component).to have_heading("No labels match your search", class: "blankslate-heading") + expect(rendered_component).to have_octicon(:search) + end + end end end diff --git a/spec/components/admin/labels/table_component_spec.rb b/spec/components/admin/labels/table_component_spec.rb index c02210427679..960c1000f068 100644 --- a/spec/components/admin/labels/table_component_spec.rb +++ b/spec/components/admin/labels/table_component_spec.rb @@ -34,70 +34,39 @@ include Rails.application.routes.url_helpers shared_let(:admin) { create(:admin) } + shared_let(:used) { create(:label, name: "Bug") } + shared_let(:unused) { create(:label, name: "Feature") } + shared_let(:labelings) { create_list(:labeling, 2, label: used) } - let(:query) { Queries::Labels::LabelQuery.new } + let(:rows) { Label.with_usage_count.order(:name).paginate(page: 1, per_page: 10) } subject(:rendered_component) do with_request_url "/admin/labels" do - render_inline(described_class.new(rows:, query:)) + render_inline(described_class.new(rows:)) 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) } + 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 - 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_css("[data-test-selector='label-row-#{used.id}']") - expect(rendered_component).to have_css("[data-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 = page.find("[data-test-selector='label-row-#{used.id}']") - expect(used_row).to have_css("[data-test-selector='label-usage']", text: "2 work packages") - - unused_row = page.find("[data-test-selector='label-row-#{unused.id}']") - expect(unused_row).to have_css("[data-test-selector='label-usage']", text: "-") - end - - it "renders a pagination footer" do - expect(rendered_component).to have_css(".op-pagination") - end + it "renders one row per label" do + expect(rendered_component).to have_css("[data-test-selector='label-row-#{used.id}']") + expect(rendered_component).to have_css("[data-test-selector='label-row-#{unused.id}']") end - context "without labels" do - let(:rows) { Label.with_usage_count.paginate(page: 1, per_page: 10) } - - 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 + it "renders the usage count for a used label and a dash for an unused one" do + rendered_component - 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 + used_row = page.find("[data-test-selector='label-row-#{used.id}']") + expect(used_row).to have_css("[data-test-selector='label-usage']", text: "2 work packages") - it_behaves_like "rendering Blank Slate", - heading: I18n.t("admin.labels.table_component.no_matches.title"), - icon: :search + unused_row = page.find("[data-test-selector='label-row-#{unused.id}']") + expect(unused_row).to have_css("[data-test-selector='label-usage']", text: "-") + end - it "renders the no-matches description" do - expect(rendered_component).to have_text(I18n.t("admin.labels.table_component.no_matches.description")) - end - end + it "renders a pagination footer" do + expect(rendered_component).to have_css(".op-pagination") end end From 654a177ad558ee2bb03a5eda90694595ec4214ea Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 17 Sep 2026 15:37:05 +0300 Subject: [PATCH 08/22] Let the label model compute the page a label sorts on --- app/controllers/admin/labels_controller.rb | 11 ++--------- app/models/label.rb | 9 +++++++++ spec/models/label_spec.rb | 17 +++++++++++++++++ 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/app/controllers/admin/labels_controller.rb b/app/controllers/admin/labels_controller.rb index c8c29888da2a..9bf3899ba89a 100644 --- a/app/controllers/admin/labels_controller.rb +++ b/app/controllers/admin/labels_controller.rb @@ -125,15 +125,8 @@ def find_label def redirect_to_created_label(label) flash[:notice] = t(:notice_successful_create) - redirect_to admin_labels_path(page: page_containing(label)) - end - - def page_containing(label) - name = Label.arel_table[:name] - quoted_name = Arel::Nodes::NamedFunction.new("LOWER", [Arel::Nodes.build_quoted(label.name)]) - preceding_count = Label.where(name.lower.lt(quoted_name)).count - page = (preceding_count / per_page_param) + 1 - page if page > 1 + page = Label.page_of(label, per_page: per_page_param) + redirect_to admin_labels_path(page: page > 1 ? page : nil) end def label_params diff --git a/app/models/label.rb b/app/models/label.rb index 763f5b5d9d29..4112714df1dd 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -36,10 +36,19 @@ class Label < ApplicationRecord select("labels.*, (SELECT COUNT(*) FROM labelings WHERE labelings.label_id = labels.id) AS usage_count") } + scope :named_before, ->(label) { + quoted_name = Arel::Nodes::NamedFunction.new("LOWER", [Arel::Nodes.build_quoted(label.name)]) + where(arel_table[:name].lower.lt(quoted_name)) + } + normalizes :name, with: -> { it.squish } validates :name, presence: true, uniqueness: { case_sensitive: false }, length: { maximum: 255 } + + def self.page_of(label, per_page:) + (named_before(label).count / per_page) + 1 + 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) From 84826ec53b0cea07e8f332cbd93ddcb2689dba96 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 17 Sep 2026 15:57:11 +0300 Subject: [PATCH 09/22] Word the label delete dialog as designed --- .../labels/delete_dialog_component.html.erb | 3 ++- .../admin/labels/delete_dialog_component.rb | 4 ---- config/locales/en.yml | 7 ++---- .../labels/delete_dialog_component_spec.rb | 22 ++++++------------- spec/requests/admin/labels_spec.rb | 12 +++------- 5 files changed, 14 insertions(+), 34 deletions(-) diff --git a/app/components/admin/labels/delete_dialog_component.html.erb b/app/components/admin/labels/delete_dialog_component.html.erb index 40e514942503..1a16e20f81c1 100644 --- a/app/components/admin/labels/delete_dialog_component.html.erb +++ b/app/components/admin/labels/delete_dialog_component.html.erb @@ -32,13 +32,14 @@ See COPYRIGHT and LICENSE files for more details. 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", count: usage_count)) + message.with_description_content(t(".description_html", name: label.name)) end dialog.with_confirmation_check_box_content(t(".confirmation")) diff --git a/app/components/admin/labels/delete_dialog_component.rb b/app/components/admin/labels/delete_dialog_component.rb index 85becc55222c..c12073d20b59 100644 --- a/app/components/admin/labels/delete_dialog_component.rb +++ b/app/components/admin/labels/delete_dialog_component.rb @@ -43,10 +43,6 @@ def form_arguments method: :delete } end - - def usage_count - label.labelings.count - end end end end diff --git a/config/locales/en.yml b/config/locales/en.yml index a05535657da0..f6936463f3d5 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1761,11 +1761,8 @@ en: labels: delete_dialog_component: - confirmation: "I understand that this action is not reversible." - description: - one: "This will remove the label from 1 work package that currently uses it. Are you sure you want to delete it?" - other: "This will remove the label from %{count} work packages that currently use it. Are you sure you want to delete it?" - zero: "This label is not used by any work package. Are you sure you want to delete it?" + 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: diff --git a/spec/components/admin/labels/delete_dialog_component_spec.rb b/spec/components/admin/labels/delete_dialog_component_spec.rb index d4bb847e750b..799a9b8b2e32 100644 --- a/spec/components/admin/labels/delete_dialog_component_spec.rb +++ b/spec/components/admin/labels/delete_dialog_component_spec.rb @@ -41,27 +41,18 @@ let(:label) { create(:label, name: "Machine Learning") } - it "renders the confirmation heading and the no-usage description" do + 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 label is not used by any work package. Are you sure you want to delete it?") - end - - context "with a used label" do - before { create_list(:labeling, 2, label:) } - - it "renders the pluralised usage count in the description" do - rendered_component - - expect(page).to have_text("This will remove the label from 2 work packages that currently use it.") - end + 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") + 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 @@ -78,9 +69,10 @@ expect(page).to have_no_button("Delete permanently") end - it "labels the cancel button Cancel" do + it "labels the cancel button Close" do rendered_component - expect(page).to have_button("Cancel") + expect(page).to have_button("Close") + expect(page).to have_no_button("Cancel") end end diff --git a/spec/requests/admin/labels_spec.rb b/spec/requests/admin/labels_spec.rb index 8ee5b47efc4d..ebbd911c64ac 100644 --- a/spec/requests/admin/labels_spec.rb +++ b/spec/requests/admin/labels_spec.rb @@ -133,15 +133,9 @@ expect(response).to have_http_status(:ok) expect(response.body).to include("Delete this label?") - expect(response.body).to include("This label is not used by any work package") - end - - it "reports the label's usage count in the confirmation copy" do - create_list(:labeling, 2, label:) - - get deletion_dialog_admin_label_path(label), headers: turbo_stream_headers - - expect(response.body).to include("This will remove the label from 2 work packages that currently use it") + expect(response.body).to include( + "This will remove the label Machine Learning from all work packages in all projects." + ) end end From 27f406406b319cccd19d6559aa2e652b049c83dc Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 17 Sep 2026 16:23:07 +0300 Subject: [PATCH 10/22] Remove unspecified divider --- app/components/admin/labels/row_component.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/components/admin/labels/row_component.rb b/app/components/admin/labels/row_component.rb index 67daa88f46e5..8db4d647cc91 100644 --- a/app/components/admin/labels/row_component.rb +++ b/app/components/admin/labels/row_component.rb @@ -79,8 +79,6 @@ def action_menu item.with_leading_visual_icon(icon: :pencil) end - menu.with_divider - menu.with_item( label: t(:button_delete), scheme: :danger, From d94dff52eba25364b50bc59fa3ef7701f11e4cd1 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 17 Sep 2026 17:38:36 +0300 Subject: [PATCH 11/22] Assert the labels admin components through accessible selectors Component specs now use the test selector helpers, the accessible role selectors and a new :async_dialog_trigger selector instead of raw DOM queries, and stop auditing class lists and Stimulus wiring. --- .../admin/labels/list_component_spec.rb | 15 ++-- .../admin/labels/row_component_spec.rb | 33 ++------- .../admin/labels/sub_header_component_spec.rb | 12 ++-- .../admin/labels/table_component_spec.rb | 12 ++-- .../support/capybara/async_dialog_selector.rb | 72 +++++++++++++++++++ spec/support/finders/test_selector_finders.rb | 1 + 6 files changed, 95 insertions(+), 50 deletions(-) create mode 100644 spec/support/capybara/async_dialog_selector.rb diff --git a/spec/components/admin/labels/list_component_spec.rb b/spec/components/admin/labels/list_component_spec.rb index 9c20a872d807..473436988aae 100644 --- a/spec/components/admin/labels/list_component_spec.rb +++ b/spec/components/admin/labels/list_component_spec.rb @@ -52,25 +52,20 @@ context "with labels" do shared_let(:label) { create(:label, name: "Bug") } - it "renders the table inside the wrapper" do - expect(rendered_component).to have_css(".Box") - expect(rendered_component).to have_css("[data-test-selector='label-row-#{label.id}']") - end - - it "renders the column headers" do - expect(rendered_component).to have_css("[role='columnheader']", text: "Used in") + it "renders the table instead of the blank slate" do + expect(rendered_component).to have_selector(:columnheader, "Used in") end end context "without labels" do it "renders the blank slate instead of the table header" do expect(rendered_component).to have_test_selector("labels-blank-slate") - expect(rendered_component).to have_no_css("[role='columnheader']") + expect(rendered_component).to have_no_selector(:columnheader) end context "with no active filter" do it "shows the empty-state copy" do - expect(rendered_component).to have_heading("No labels yet", class: "blankslate-heading") + expect(rendered_component).to have_heading("No labels yet") expect(rendered_component).to have_octicon(:tag) end end @@ -83,7 +78,7 @@ end it "shows the no-matches copy" do - expect(rendered_component).to have_heading("No labels match your search", class: "blankslate-heading") + expect(rendered_component).to have_heading("No labels match your search") expect(rendered_component).to have_octicon(:search) end end diff --git a/spec/components/admin/labels/row_component_spec.rb b/spec/components/admin/labels/row_component_spec.rb index 120241a58485..5db226a86696 100644 --- a/spec/components/admin/labels/row_component_spec.rb +++ b/spec/components/admin/labels/row_component_spec.rb @@ -48,11 +48,11 @@ let(:label) { Label.with_usage_count.find(record.id) } it "renders the name as a chip" do - expect(rendered_component).to have_css("[data-test-selector='label-name']", text: "Bug") + expect(rendered_component).to have_test_selector("label-name", text: "Bug") end it "renders the pluralised usage count" do - expect(rendered_component).to have_css("[data-test-selector='label-usage']", text: "3 work packages") + expect(rendered_component).to have_test_selector("label-usage", text: "3 work packages") end end @@ -62,18 +62,7 @@ let(:label) { Label.with_usage_count.find(record.id) } it "renders a dash instead of a count" do - expect(rendered_component).to have_css("[data-test-selector='label-usage']", text: "-") - end - end - - context "with a long name" do - shared_let(:record) { create(:label, name: "A" * 120) } - - let(:label) { Label.with_usage_count.find(record.id) } - - it "truncates the chip and exposes the full name as a title tooltip" do - expect(rendered_component).to have_css("[data-test-selector='label-name'].ellipsis.width-fit[title='#{record.name}']", - visible: :all) + expect(rendered_component).to have_test_selector("label-usage", text: "-") end end @@ -82,20 +71,12 @@ let(:label) { Label.with_usage_count.find(record.id) } - it "links Rename to the edit dialog as an async-dialog request" do - expect(rendered_component).to have_css( - "a[href='#{edit_dialog_admin_label_path(label)}'][data-controller='async-dialog']", - text: "Rename", - visible: :all - ) + 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 as a danger, async-dialog request" do - expect(rendered_component).to have_css( - "a[href='#{deletion_dialog_admin_label_path(label)}'][data-controller='async-dialog']", - text: "Delete", - visible: :all - ) + 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 index 41fe838300a2..50661c6ae5d4 100644 --- a/spec/components/admin/labels/sub_header_component_spec.rb +++ b/spec/components/admin/labels/sub_header_component_spec.rb @@ -41,11 +41,10 @@ let(:query) { Queries::Labels::LabelQuery.new } - it "wires the filters-form controller to the search endpoint" do + it "searches against the labels search endpoint" do rendered_component - header = page.find("[data-test-selector='labels-sub-header']", visible: :all) - expect(header["data-controller"]).to eq("filter--filters-form") + 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 @@ -66,15 +65,12 @@ it "renders the add label button pointing at the create dialog" do rendered_component - expect(page).to have_css( - "a[href='#{new_dialog_admin_labels_path}'][data-controller='async-dialog']", - text: "Label" - ) + 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_css("a[aria-label='Add label']", text: "Label") + 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 index 960c1000f068..932cf88d598c 100644 --- a/spec/components/admin/labels/table_component_spec.rb +++ b/spec/components/admin/labels/table_component_spec.rb @@ -52,18 +52,18 @@ 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_css("[data-test-selector='label-row-#{used.id}']") - expect(rendered_component).to have_css("[data-test-selector='label-row-#{unused.id}']") + 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 = page.find("[data-test-selector='label-row-#{used.id}']") - expect(used_row).to have_css("[data-test-selector='label-usage']", text: "2 work packages") + used_row = find_test_selector("label-row-#{used.id}") + expect(used_row).to have_test_selector("label-usage", text: "2 work packages") - unused_row = page.find("[data-test-selector='label-row-#{unused.id}']") - expect(unused_row).to have_css("[data-test-selector='label-usage']", text: "-") + 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 diff --git a/spec/support/capybara/async_dialog_selector.rb b/spec/support/capybara/async_dialog_selector.rb new file mode 100644 index 000000000000..64185156a976 --- /dev/null +++ b/spec/support/capybara/async_dialog_selector.rb @@ -0,0 +1,72 @@ +# 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. +#++ + +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 + def have_async_dialog_trigger(locator = nil, **, &) + Matchers::HaveSelector.new(:async_dialog_trigger, locator, **, &) + end + + 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 From 06493a16c3164d5f654db909f0f9beb28c4279c7 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 17 Sep 2026 17:41:33 +0300 Subject: [PATCH 12/22] Redirect a renamed label to the page it sorts on Renaming can move a label across an alphabetical page boundary, and landing back on page one hid the label the admin just renamed. Update now shares the page computation create already used. --- app/controllers/admin/labels_controller.rb | 11 ++++------- spec/requests/admin/labels_spec.rb | 10 ++++++++++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/app/controllers/admin/labels_controller.rb b/app/controllers/admin/labels_controller.rb index 9bf3899ba89a..84c662879de3 100644 --- a/app/controllers/admin/labels_controller.rb +++ b/app/controllers/admin/labels_controller.rb @@ -69,7 +69,7 @@ def create .new(user: current_user) .call(label_params) - result.on_success { redirect_to_created_label(result.result) } + result.on_success { redirect_to_label_page(result.result, notice: t(:notice_successful_create)) } result.on_failure do update_via_turbo_stream( @@ -85,10 +85,7 @@ def update .new(user: current_user, model: @label) .call(label_params) - result.on_success do - flash[:notice] = t(:notice_successful_update) - redirect_to admin_labels_path - end + result.on_success { redirect_to_label_page(result.result, notice: t(:notice_successful_update)) } result.on_failure do update_via_turbo_stream( @@ -123,8 +120,8 @@ def find_label @label = Label.find(params.expect(:id)) end - def redirect_to_created_label(label) - flash[:notice] = t(:notice_successful_create) + 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 diff --git a/spec/requests/admin/labels_spec.rb b/spec/requests/admin/labels_spec.rb index ebbd911c64ac..ba51a2a62f67 100644 --- a/spec/requests/admin/labels_spec.rb +++ b/spec/requests/admin/labels_spec.rb @@ -188,6 +188,16 @@ expect(label.reload.name).to eq("Defect") end + 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 + it "rejects a duplicate name" do create(:label, name: "Feature") From f2574d46a316b6df1dc7b7552b8c3b31aaef0df4 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 17 Sep 2026 17:41:36 +0300 Subject: [PATCH 13/22] Derive the label dialog mode from the record The create and rename dialogs carried a state flag that only ever mirrored whether the label was persisted, and the form component accepted it unvalidated. Both now read the record instead. The form component spec builds an unsaved label for the failed-create render, since a stubbed one reports itself persisted and rendered the rename form. --- .../admin/labels/dialog_component.html.erb | 2 +- app/components/admin/labels/dialog_component.rb | 15 ++++----------- app/components/admin/labels/form_component.rb | 17 +++++------------ app/controllers/admin/labels_controller.rb | 8 ++++---- .../admin/labels/dialog_component_spec.rb | 8 +++----- .../admin/labels/form_component_spec.rb | 13 ++++++++----- 6 files changed, 25 insertions(+), 38 deletions(-) diff --git a/app/components/admin/labels/dialog_component.html.erb b/app/components/admin/labels/dialog_component.html.erb index f90401641e19..e877b1deed52 100644 --- a/app/components/admin/labels/dialog_component.html.erb +++ b/app/components/admin/labels/dialog_component.html.erb @@ -38,7 +38,7 @@ See COPYRIGHT and LICENSE files for more details. d.with_header(variant: :large) d.with_body do - render(Admin::Labels::FormComponent.new(label:, state:)) + render(Admin::Labels::FormComponent.new(label:)) end d.with_footer do diff --git a/app/components/admin/labels/dialog_component.rb b/app/components/admin/labels/dialog_component.rb index 5d71cdaaf292..5514203699b9 100644 --- a/app/components/admin/labels/dialog_component.rb +++ b/app/components/admin/labels/dialog_component.rb @@ -33,32 +33,25 @@ module Labels class DialogComponent < ApplicationComponent include OpTurbo::Streamable include OpPrimer::ComponentHelpers - include Primer::FetchOrFallbackHelper DIALOG_ID = "admin-label-dialog" - STATE_DEFAULT = :create - STATE_OPTIONS = [STATE_DEFAULT, :rename].freeze + attr_reader :label - attr_reader :label, :state - - delegate :create?, :rename?, to: :state - - def initialize(label:, state: STATE_DEFAULT) + def initialize(label:) super() @label = label - @state = ActiveSupport::StringInquirer.new(fetch_or_fallback(STATE_OPTIONS, state, STATE_DEFAULT).to_s) end private def title - create? ? t(".create_title") : t(".rename_title", name: label.name) + label.persisted? ? t(".rename_title", name: label.name) : t(".create_title") end def button_caption - create? ? t(:button_create) : t(:button_rename) + label.persisted? ? t(:button_rename) : t(:button_create) end end end diff --git a/app/components/admin/labels/form_component.rb b/app/components/admin/labels/form_component.rb index b735e91183d2..c9268eb6f51a 100644 --- a/app/components/admin/labels/form_component.rb +++ b/app/components/admin/labels/form_component.rb @@ -36,27 +36,20 @@ class FormComponent < ApplicationComponent FORM_ID = "admin-label-form" - attr_reader :state - - delegate :create?, to: :state - - # `model` (not `label`) is deliberate: this component is the Rails form - # template context for its nested primer_form_with block, and a reader - # named `label` would shadow ActionView::Helpers::FormHelper#label. - def initialize(label:, state:) + # 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) - - @state = ActiveSupport::StringInquirer.new(state.to_s) end private def http_method - create? ? :post : :patch + model.persisted? ? :patch : :post end def form_url - create? ? admin_labels_path : admin_label_path(model) + model.persisted? ? admin_label_path(model) : admin_labels_path end end end diff --git a/app/controllers/admin/labels_controller.rb b/app/controllers/admin/labels_controller.rb index 84c662879de3..68aa3731cacd 100644 --- a/app/controllers/admin/labels_controller.rb +++ b/app/controllers/admin/labels_controller.rb @@ -57,11 +57,11 @@ def search end def new_dialog - respond_with_dialog Admin::Labels::DialogComponent.new(label: Label.new, state: :create) + respond_with_dialog Admin::Labels::DialogComponent.new(label: Label.new) end def edit_dialog - respond_with_dialog Admin::Labels::DialogComponent.new(label: @label, state: :rename) + respond_with_dialog Admin::Labels::DialogComponent.new(label: @label) end def create @@ -73,7 +73,7 @@ def create result.on_failure do update_via_turbo_stream( - component: Admin::Labels::FormComponent.new(label: result.result, state: :create), + component: Admin::Labels::FormComponent.new(label: result.result), status: :unprocessable_entity ) respond_with_turbo_streams @@ -89,7 +89,7 @@ def update result.on_failure do update_via_turbo_stream( - component: Admin::Labels::FormComponent.new(label: result.result, state: :rename), + component: Admin::Labels::FormComponent.new(label: result.result), status: :unprocessable_entity ) respond_with_turbo_streams diff --git a/spec/components/admin/labels/dialog_component_spec.rb b/spec/components/admin/labels/dialog_component_spec.rb index c099aacdb3d4..89b85025ef62 100644 --- a/spec/components/admin/labels/dialog_component_spec.rb +++ b/spec/components/admin/labels/dialog_component_spec.rb @@ -35,13 +35,12 @@ subject(:rendered_component) do with_request_url "/admin/labels" do - render_inline(described_class.new(label:, state:)) + render_inline(described_class.new(label:)) end end - context "in the create state" do + context "for a new label" do let(:label) { Label.new } - let(:state) { :create } it "renders the create title" do rendered_component @@ -62,9 +61,8 @@ end end - context "in the rename state" do + context "for a persisted label" do let(:label) { create(:label, name: "Machine Learning") } - let(:state) { :rename } it "renders the rename title with the label's name" do rendered_component diff --git a/spec/components/admin/labels/form_component_spec.rb b/spec/components/admin/labels/form_component_spec.rb index 847c6baa90ab..327b7379d45d 100644 --- a/spec/components/admin/labels/form_component_spec.rb +++ b/spec/components/admin/labels/form_component_spec.rb @@ -35,13 +35,12 @@ subject(:rendered_component) do with_request_url "/admin/labels" do - render_inline(described_class.new(label:, state:)) + render_inline(described_class.new(label:)) end end context "for a new label" do let(:label) { Label.new } - let(:state) { :create } it "posts to the labels collection route" do rendered_component @@ -58,7 +57,6 @@ context "for a persisted label" do let(:label) { create(:label, name: "Machine Learning") } - let(:state) { :rename } it "patches the label's own route" do rendered_component @@ -75,11 +73,16 @@ end context "with a validation error on the name" do - let(:label) { build_stubbed(:label, name: "Bug") } - let(:state) { :create } + 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 From e018919887e7b3efdfe14ae4dc4814e6f36997f1 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 17 Sep 2026 17:41:40 +0300 Subject: [PATCH 14/22] Assert the labels menu entry through the sidebar links Checking the index body for the word "Labels" passed on the page title alone. Both flag states now inspect the sidebar anchors. --- spec/requests/admin/labels_spec.rb | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/spec/requests/admin/labels_spec.rb b/spec/requests/admin/labels_spec.rb index ba51a2a62f67..e55a6d27719e 100644 --- a/spec/requests/admin/labels_spec.rb +++ b/spec/requests/admin/labels_spec.rb @@ -38,6 +38,10 @@ current_user { admin } + def sidebar_menu_links(body) + Nokogiri::HTML5(body).css("#menu-sidebar a[href]").map { URI(it["href"]).path } + end + describe "GET /admin/labels" do context "with labels" do let!(:used) { create(:label, name: "Bug") } @@ -72,9 +76,9 @@ end it "renders the admin menu entry" do - get admin_labels_path + get admin_index_path - expect(response.body).to include("Labels") + expect(sidebar_menu_links(response.body)).to include(admin_labels_path) end end @@ -272,7 +276,8 @@ it "does not render the admin menu entry" do get admin_index_path - expect(response.body).not_to include(">Labels<") + + expect(sidebar_menu_links(response.body)).not_to include(admin_labels_path) end end end From 17065d2cac1c9c910542ee3fa11af380047c30af Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 17 Sep 2026 17:41:42 +0300 Subject: [PATCH 15/22] Drop the blank-name check from the label creation feature spec The name field is required, so the browser blocked the submit before any request and the assertions passed without exercising the server. The request spec covers the blank-name rejection. --- spec/features/admin/labels/manage_spec.rb | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/spec/features/admin/labels/manage_spec.rb b/spec/features/admin/labels/manage_spec.rb index 6d0b1267510e..67f746e4f12f 100644 --- a/spec/features/admin/labels/manage_spec.rb +++ b/spec/features/admin/labels/manage_spec.rb @@ -65,15 +65,11 @@ def expect_row(label, usage:) expect(page).to have_css(".blankslate", text: "No labels yet") end - it "creates a label, rejecting a blank name first" do + it "creates a label" do visit admin_labels_path find_test_selector("add-label-button").click page.within_modal("Create label") do - click_on "Create" - expect(page).to have_button("Create") - expect(Label.count).to eq(0) - fill_in "Name", with: "Machine Learning" click_on "Create" end From a400e8f799adaa9e3998b40f9699a5278d0082e2 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 17 Sep 2026 17:41:44 +0300 Subject: [PATCH 16/22] Clear the labels search through the clear button in the feature spec Cuprite fires no input event when filling a field with an empty string, so the live search never reloaded and the example failed deterministically. The clear button dispatches the input event the filter form listens for. --- spec/features/admin/labels/manage_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/features/admin/labels/manage_spec.rb b/spec/features/admin/labels/manage_spec.rb index 67f746e4f12f..edb167157853 100644 --- a/spec/features/admin/labels/manage_spec.rb +++ b/spec/features/admin/labels/manage_spec.rb @@ -142,7 +142,7 @@ def expect_row(label, usage:) wait_for_network_idle expect(page).to have_css(".blankslate", text: "No labels match your search") - fill_in "Search", with: "" + 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)) From a7e99d13ebaf462ca3f03f4e8ca616ba89fdc89e Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 17 Sep 2026 17:43:06 +0300 Subject: [PATCH 17/22] Trim the labels request spec to what the feature spec cannot cover Authorization, flag gating on mutations, the search pagination link target, page redirects after create and rename, and the delete failure branch stay; user flows live in the feature spec. --- spec/requests/admin/labels_spec.rb | 152 ++--------------------------- 1 file changed, 6 insertions(+), 146 deletions(-) diff --git a/spec/requests/admin/labels_spec.rb b/spec/requests/admin/labels_spec.rb index e55a6d27719e..6ce51c43ef91 100644 --- a/spec/requests/admin/labels_spec.rb +++ b/spec/requests/admin/labels_spec.rb @@ -42,117 +42,24 @@ def sidebar_menu_links(body) Nokogiri::HTML5(body).css("#menu-sidebar a[href]").map { URI(it["href"]).path } end - describe "GET /admin/labels" do - context "with labels" do - let!(:used) { create(:label, name: "Bug") } - let!(:unused) { create(:label, name: "Feature") } + it "renders the admin menu entry" do + get admin_index_path - before { create_list(:labeling, 2, label: used) } - - it "lists the labels with their usage count" do - get admin_labels_path - - expect(response).to have_http_status(:ok) - expect(response.body).to include("Bug") - expect(response.body).to include("Feature") - expect(response.body).to include("2 work packages") - end - - it "renders a dash for a label without any usage" do - get admin_labels_path - - row = Nokogiri::HTML5.fragment(response.body).at_css("[data-test-selector='label-row-#{unused.id}']") - expect(row.at_css("[data-test-selector='label-usage']").text.strip).to eq("-") - end - end - - context "without any labels" do - it "renders the blank slate" do - get admin_labels_path - - expect(response).to have_http_status(:ok) - expect(response.body).to include("No labels yet") - end - end - - it "renders the admin menu entry" do - get admin_index_path - - expect(sidebar_menu_links(response.body)).to include(admin_labels_path) - end + 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 - let!(:matching) { create(:label, name: "urgent-bug") } - let!(:other) { create(:label, name: "docs-update") } - - it "returns a turbo_stream response" do - get search_admin_labels_path, params: { per_page: 1 }, headers: turbo_stream_headers - - expect(response).to have_http_status(:ok) - expect(response.media_type).to eq("text/vnd.turbo-stream.html") - end - - it "narrows the list to labels matching the name filter" do - filters = [{ name: { operator: "~", values: ["urgent"] } }].to_json - - get search_admin_labels_path, params: { filters: }, headers: turbo_stream_headers - - expect(response.body).to include(%(data-test-selector="label-row-#{matching.id}")) - expect(response.body).not_to include(%(data-test-selector="label-row-#{other.id}")) - end + 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 "GET /admin/labels/new_dialog" do - it "renders the create dialog" do - get new_dialog_admin_labels_path, headers: turbo_stream_headers - - expect(response).to have_http_status(:ok) - expect(response.body).to include("Create label") - end - end - - describe "GET /admin/labels/:id/edit_dialog" do - let!(:label) { create(:label, name: "Machine Learning") } - - it "renders the rename dialog prefilled with the current name" do - get edit_dialog_admin_label_path(label), headers: turbo_stream_headers - - expect(response).to have_http_status(:ok) - expect(response.body).to include("Rename "Machine Learning"").or include("Rename \"Machine Learning\"") - end - end - - describe "GET /admin/labels/:id/deletion_dialog" do - let!(:label) { create(:label, name: "Machine Learning") } - - it "renders the danger dialog" do - get deletion_dialog_admin_label_path(label), headers: turbo_stream_headers - - expect(response).to have_http_status(:ok) - expect(response.body).to include("Delete this label?") - expect(response.body).to include( - "This will remove the label Machine Learning from all work packages in all projects." - ) - end - end - describe "POST /admin/labels" do - it "creates the label authored by the current user and redirects to the index" do - post admin_labels_path, params: { label: { name: "Regression" } } - - expect(response).to redirect_to(admin_labels_path) - - label = Label.find_by!(name: "Regression") - expect(label.author).to eq(admin) - end - 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") @@ -162,36 +69,13 @@ def sidebar_menu_links(body) post admin_labels_path, params: { label: { name: "Zulu" }, per_page: 2 } expect(response).to redirect_to(admin_labels_path(page: 2)) - end - - it "rejects a blank name" do - post admin_labels_path, params: { label: { name: "" } }, headers: turbo_stream_headers - - expect(response).to have_http_status(:unprocessable_entity) - expect(Label.count).to eq(0) - end - - it "rejects a case-insensitive duplicate name" do - create(:label, name: "Bug") - - post admin_labels_path, params: { label: { name: "BUG" } }, headers: turbo_stream_headers - - expect(response).to have_http_status(:unprocessable_entity) - expect(response.body).to include("A label with this name already exists. Please use another one.") - expect(Label.where(name: "BUG")).not_to exist + 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 "renames the label and redirects to the index" do - patch admin_label_path(label), params: { label: { name: "Defect" } } - - expect(response).to redirect_to(admin_labels_path) - expect(label.reload.name).to eq("Defect") - end - it "redirects to the page the renamed label sorts on", with_settings: { per_page_options: "2 5 10" } do create(:label, name: "Alpha") @@ -201,29 +85,10 @@ def sidebar_menu_links(body) expect(response).to redirect_to(admin_labels_path(page: 2)) end - - it "rejects a duplicate name" do - create(:label, name: "Feature") - - patch admin_label_path(label), params: { label: { name: "Feature" } }, headers: turbo_stream_headers - - expect(response).to have_http_status(:unprocessable_entity) - expect(response.body).to include("A label with this name already exists. Please use another one.") - expect(label.reload.name).to eq("Bug") - end end describe "DELETE /admin/labels/:id" do let!(:label) { create(:label) } - let!(:labeling) { create(:labeling, label:) } - - it "removes the label and its labelings" do - delete admin_label_path(label) - - expect(response).to redirect_to(admin_labels_path) - expect(Label.where(id: label.id)).not_to exist - expect(Labeling.where(id: labeling.id)).not_to exist - end 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") } @@ -258,11 +123,6 @@ def sidebar_menu_links(body) context "when the feature flag is inactive", with_flag: { work_package_labels: false } do let!(:label) { create(:label) } - it "responds with 404 for the list" do - get admin_labels_path - expect(response).to have_http_status(:not_found) - end - 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) From 7789dfb3eb48a1742c87bc810bb205e664c148cb Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 17 Sep 2026 17:50:13 +0300 Subject: [PATCH 18/22] Document the async dialog trigger selector --- .../support/capybara/async_dialog_selector.rb | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/spec/support/capybara/async_dialog_selector.rb b/spec/support/capybara/async_dialog_selector.rb index 64185156a976..fccc2e440b27 100644 --- a/spec/support/capybara/async_dialog_selector.rb +++ b/spec/support/capybara/async_dialog_selector.rb @@ -28,6 +28,21 @@ # 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" @@ -61,10 +76,14 @@ module Capybara module RSpecMatchers + # @param locator [String, Symbol, nil] visible text or aria-label of the trigger + # @param options [Hash] selector filters such as `href:` + # @return [Capybara::RSpecMatchers::Matchers::HaveSelector] def have_async_dialog_trigger(locator = nil, **, &) Matchers::HaveSelector.new(:async_dialog_trigger, locator, **, &) end + # Negated form of {#have_async_dialog_trigger}. def have_no_async_dialog_trigger(...) Matchers::NegatedMatcher.new(have_async_dialog_trigger(...)) end From 790e2645d01515e2766f2c851a53e1ce06a8fca1 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 17 Sep 2026 17:51:42 +0300 Subject: [PATCH 19/22] Keep the async dialog matcher docs to usage notes --- spec/support/capybara/async_dialog_selector.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/spec/support/capybara/async_dialog_selector.rb b/spec/support/capybara/async_dialog_selector.rb index fccc2e440b27..192454c6a96f 100644 --- a/spec/support/capybara/async_dialog_selector.rb +++ b/spec/support/capybara/async_dialog_selector.rb @@ -76,14 +76,12 @@ module Capybara module RSpecMatchers - # @param locator [String, Symbol, nil] visible text or aria-label of the trigger - # @param options [Hash] selector filters such as `href:` - # @return [Capybara::RSpecMatchers::Matchers::HaveSelector] + # 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 - # Negated form of {#have_async_dialog_trigger}. + # Shorthand for `have_no_selector(:async_dialog_trigger, ...)`. def have_no_async_dialog_trigger(...) Matchers::NegatedMatcher.new(have_async_dialog_trigger(...)) end From 8ff1f49e83b20b90e1adb04276e452be4795f2f3 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Tue, 22 Sep 2026 10:45:13 +0300 Subject: [PATCH 20/22] Keep the label table headers above the blank slate The empty and no-matches states stay inside the table so the column headers remain visible. --- .../admin/labels/list_component.html.erb | 16 +--- .../admin/labels/table_component.rb | 18 +++++ config/locales/en.yml | 13 ++-- .../admin/labels/list_component_spec.rb | 30 +------- .../admin/labels/table_component_spec.rb | 77 ++++++++++++++----- 5 files changed, 83 insertions(+), 71 deletions(-) diff --git a/app/components/admin/labels/list_component.html.erb b/app/components/admin/labels/list_component.html.erb index 01859245c2ec..5dfd7065c017 100644 --- a/app/components/admin/labels/list_component.html.erb +++ b/app/components/admin/labels/list_component.html.erb @@ -29,20 +29,6 @@ See COPYRIGHT and LICENSE files for more details. <%= component_wrapper do - if labels.any? - render(Admin::Labels::TableComponent.new(rows: labels)) - else - render Primer::Beta::Blankslate.new(border: true, test_selector: "labels-blank-slate") do |component| - if query.find_active_filter(:name).present? - component.with_visual_icon(icon: :search) - component.with_heading(tag: :h2).with_content(t(".no_matches.title")) - component.with_description { t(".no_matches.description") } - else - component.with_visual_icon(icon: :tag) - component.with_heading(tag: :h2).with_content(t(".blank_slate.title")) - component.with_description { t(".blank_slate.description") } - end - end - end + render(Admin::Labels::TableComponent.new(rows: labels, query:)) end %> diff --git a/app/components/admin/labels/table_component.rb b/app/components/admin/labels/table_component.rb index f6a107b07f8b..bb31d19eab5f 100644 --- a/app/components/admin/labels/table_component.rb +++ b/app/components/admin/labels/table_component.rb @@ -31,6 +31,8 @@ module Admin module Labels class TableComponent < OpPrimer::BorderBoxTableComponent + options :query + columns :name, :usage main_column :name mobile_columns :name, :usage @@ -47,6 +49,22 @@ def headers 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 diff --git a/config/locales/en.yml b/config/locales/en.yml index d3ee9ed78f38..44300648ab5c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1772,13 +1772,6 @@ en: 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." - list_component: - blank_slate: - description: "Labels created by users on work packages will appear here." - title: "No labels yet" - no_matches: - description: "Try a different search term." - title: "No labels match your search" row_component: used_in_work_packages: one: "%{count} work package" @@ -1788,9 +1781,15 @@ en: 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: diff --git a/spec/components/admin/labels/list_component_spec.rb b/spec/components/admin/labels/list_component_spec.rb index 473436988aae..4b251347fc81 100644 --- a/spec/components/admin/labels/list_component_spec.rb +++ b/spec/components/admin/labels/list_component_spec.rb @@ -52,35 +52,9 @@ context "with labels" do shared_let(:label) { create(:label, name: "Bug") } - it "renders the table instead of the blank slate" do + it "renders the table" do expect(rendered_component).to have_selector(:columnheader, "Used in") - end - end - - context "without labels" do - it "renders the blank slate instead of the table header" do - expect(rendered_component).to have_test_selector("labels-blank-slate") - expect(rendered_component).to have_no_selector(:columnheader) - end - - context "with no active filter" do - it "shows the empty-state copy" do - expect(rendered_component).to have_heading("No labels yet") - expect(rendered_component).to have_octicon(:tag) - end - 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 "shows the no-matches copy" do - expect(rendered_component).to have_heading("No labels match your search") - expect(rendered_component).to have_octicon(:search) - end + expect(rendered_component).to have_test_selector("label-row-#{label.id}") end end end diff --git a/spec/components/admin/labels/table_component_spec.rb b/spec/components/admin/labels/table_component_spec.rb index 932cf88d598c..b9bd917bab9f 100644 --- a/spec/components/admin/labels/table_component_spec.rb +++ b/spec/components/admin/labels/table_component_spec.rb @@ -34,39 +34,74 @@ include Rails.application.routes.url_helpers shared_let(:admin) { create(:admin) } - 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) } + let(:query) { Queries::Labels::LabelQuery.new } subject(:rendered_component) do with_request_url "/admin/labels" do - render_inline(described_class.new(rows:)) + render_inline(described_class.new(rows:, query:)) end end - 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 + 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) } - 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 + 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 + 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") + 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: "-") + 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 - it "renders a pagination footer" do - expect(rendered_component).to have_css(".op-pagination") + 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 From 53d9b180ce6b6154854b0ec3913b6f52e1e37499 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Tue, 22 Sep 2026 10:45:42 +0300 Subject: [PATCH 21/22] Count the labels sorting before a label with plain SQL Drops the single-use Arel scope; the redirect target reads as one comparison against the case-insensitive listing order. --- app/models/label.rb | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/app/models/label.rb b/app/models/label.rb index 4112714df1dd..c2e59bff3ef9 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -36,11 +36,6 @@ class Label < ApplicationRecord select("labels.*, (SELECT COUNT(*) FROM labelings WHERE labelings.label_id = labels.id) AS usage_count") } - scope :named_before, ->(label) { - quoted_name = Arel::Nodes::NamedFunction.new("LOWER", [Arel::Nodes.build_quoted(label.name)]) - where(arel_table[:name].lower.lt(quoted_name)) - } - normalizes :name, with: -> { it.squish } validates :name, @@ -49,6 +44,6 @@ class Label < ApplicationRecord length: { maximum: 255 } def self.page_of(label, per_page:) - (named_before(label).count / per_page) + 1 + (where("LOWER(labels.name) < LOWER(?)", label.name).count / per_page) + 1 end end From 6c70c00a3d669eb65c00df68f50f937c6d2ff2a7 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Tue, 22 Sep 2026 10:47:23 +0300 Subject: [PATCH 22/22] Drop the unused admin fixture from the labels list component spec --- spec/components/admin/labels/list_component_spec.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/spec/components/admin/labels/list_component_spec.rb b/spec/components/admin/labels/list_component_spec.rb index 4b251347fc81..06065003e025 100644 --- a/spec/components/admin/labels/list_component_spec.rb +++ b/spec/components/admin/labels/list_component_spec.rb @@ -33,8 +33,6 @@ RSpec.describe Admin::Labels::ListComponent, type: :component do include Rails.application.routes.url_helpers - shared_let(:admin) { create(:admin) } - let(:labels) { Label.with_usage_count.order(:name).paginate(page: 1, per_page: 10) } let(:query) { Queries::Labels::LabelQuery.new }