From fe813ff4031ef05c08323d837380fa73502bf692 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 18 Sep 2026 21:02:49 +0100 Subject: [PATCH 1/8] [DREAM-840] Show document types in a table The document type list composed its own grid on top of a plain border box, so its columns carried no table semantics and the sortable wiring sat on markup this screen alone defined. The shared table supplies both and lets the bespoke grid and its stylesheet go. https://community.openproject.org/wp/DREAM-840 --- modules/documents/app/components/_index.sass | 1 - .../document_types/index_component.html.erb | 26 +-- .../admin/document_types/index_component.rb | 30 +++- .../admin/document_types/index_component.sass | 15 -- .../document_types/item_component.html.erb | 87 --------- .../admin/document_types/row_component.rb | 165 ++++++++++++++++++ .../{item_component.rb => table_component.rb} | 54 +++--- .../document_types/index_component_spec.rb | 52 ++++-- .../document_types/row_component_spec.rb | 120 +++++++++++++ .../document_types/table_component_spec.rb | 114 ++++++++++++ .../admin/settings/document_types_spec.rb | 62 ++++--- .../shared/components/sortable_lists.rb | 19 +- .../shared/enumeration_admin_helpers.rb | 4 +- 13 files changed, 557 insertions(+), 192 deletions(-) delete mode 100644 modules/documents/app/components/documents/admin/document_types/index_component.sass delete mode 100644 modules/documents/app/components/documents/admin/document_types/item_component.html.erb create mode 100644 modules/documents/app/components/documents/admin/document_types/row_component.rb rename modules/documents/app/components/documents/admin/document_types/{item_component.rb => table_component.rb} (50%) create mode 100644 modules/documents/spec/components/documents/admin/document_types/row_component_spec.rb create mode 100644 modules/documents/spec/components/documents/admin/document_types/table_component_spec.rb diff --git a/modules/documents/app/components/_index.sass b/modules/documents/app/components/_index.sass index 21730d56dfaa..b9a8877fb022 100644 --- a/modules/documents/app/components/_index.sass +++ b/modules/documents/app/components/_index.sass @@ -1,3 +1,2 @@ -@import "documents/admin/document_types/index_component" @import "documents/show_edit_view/block_note_editor_component" @import "documents/show_edit_view/page_layout_component" diff --git a/modules/documents/app/components/documents/admin/document_types/index_component.html.erb b/modules/documents/app/components/documents/admin/document_types/index_component.html.erb index a75de8b87310..73e6bcd3762c 100644 --- a/modules/documents/app/components/documents/admin/document_types/index_component.html.erb +++ b/modules/documents/app/components/documents/admin/document_types/index_component.html.erb @@ -48,31 +48,7 @@ end flex.with_row do - render(border_box_container(data: list_data)) do |component| - component.with_header(font_weight: :bold) do - grid_layout("op-documents-types-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)) { I18n.t("documents.index_page.type") } - end - - grid.with_area(:"documents-count", tag: :div, hide: :sm) do - render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t("label_documents") } - end - end - end - - if document_types.empty? - component.with_row do - render(Primer::Beta::Text.new(color: :subtle)) { t(:no_results_title_text) } - end - else - document_types.each do |document_type| - component.with_row(test_selector: "document-type-row-#{document_type.id}", data: item_data(document_type)) do - render(item_component_class.new(enumeration: document_type)) - end - end - end - end + render(::Documents::Admin::DocumentTypes::TableComponent.new(rows: document_types)) end end end diff --git a/modules/documents/app/components/documents/admin/document_types/index_component.rb b/modules/documents/app/components/documents/admin/document_types/index_component.rb index 9debcddf68c3..3b2ac6825337 100644 --- a/modules/documents/app/components/documents/admin/document_types/index_component.rb +++ b/modules/documents/app/components/documents/admin/document_types/index_component.rb @@ -31,11 +31,35 @@ module Documents module Admin module DocumentTypes - class IndexComponent < ::Admin::Enumerations::IndexComponent + class IndexComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(enumerations:) + super() + @enumerations = enumerations + end + + private + + attr_reader :enumerations + alias_method :document_types, :enumerations - def item_component_class - ::Documents::Admin::DocumentTypes::ItemComponent + def wrapper_data_attributes + { + controller: "sortable-lists", + sortable_lists_move_url_template_value: move_url_template, + sortable_lists_sortable_lists__list_outlet: "##{wrapper_key} [data-controller~='sortable-lists--list']", + sortable_lists_sortable_lists__item_outlet: "##{wrapper_key} [data-controller~='sortable-lists--item']" + } + end + + # Built from the route helper with a sentinel so relative-URL-root + # installations keep working; {id} is expanded client-side. + def move_url_template + id_placeholder = "__id__" + move_admin_settings_document_type_path(id_placeholder).sub(id_placeholder, "{id}") end end end diff --git a/modules/documents/app/components/documents/admin/document_types/index_component.sass b/modules/documents/app/components/documents/admin/document_types/index_component.sass deleted file mode 100644 index 76691df2e3f2..000000000000 --- a/modules/documents/app/components/documents/admin/document_types/index_component.sass +++ /dev/null @@ -1,15 +0,0 @@ -.op-documents-types-list--header, -.op-documents-types-list--item - display: grid - grid-template-columns: 20px 2fr 1fr 1fr - grid-template-areas: "drag-handle name documents-count actions" - - &--actions - justify-self: end - -@media screen and (max-width: $breakpoint-sm) - .op-documents-types-list--header, - .op-documents-types-list--item - grid-template-columns: 20px 1fr 1fr - grid-template-areas: "drag-handle name actions" - column-gap: 5px diff --git a/modules/documents/app/components/documents/admin/document_types/item_component.html.erb b/modules/documents/app/components/documents/admin/document_types/item_component.html.erb deleted file mode 100644 index 346b7a40202e..000000000000 --- a/modules/documents/app/components/documents/admin/document_types/item_component.html.erb +++ /dev/null @@ -1,87 +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. - - ++# -%> - -<%= component_wrapper do - grid_layout("op-documents-types-list--item", tag: :div, align_items: :center) do |grid| - grid.with_area(:"drag-handle", tag: :div, classes: "hide-when-print") do - render(Primer::OpenProject::DragHandle.new(data: { sortable_lists__item_target: "handle" })) - end - - grid.with_area(:name, tag: :div, classes: "ellipsis") do - flex_layout do |flex| - flex.with_column do - render( - Primer::Beta::Link.new( - href: helpers.url_for(action: :edit, id: document_type), - underline: false - ) - ) do - render(Primer::Beta::Text.new(font_weight: :bold)) { document_type.name } - end - end - - unless document_type.active? - flex.with_column(ml: 2) do - render(Primer::Beta::Label.new(scheme: :default, test_selector: "label-inactive")) do - I18n.t(:label_inactive) - end - end - end - - if document_type.is_default? - flex.with_column(ml: 2) do - render(Primer::Beta::Label.new(scheme: :primary, test_selector: "label-is-default")) do - I18n.t(:label_default) - end - end - end - end - end - - grid.with_area(:"documents-count", tag: :div, hide: :sm) do - render(Primer::Beta::Text.new(color: :subtle, test_selector: "documents-count")) do - document_type.documents_count.to_s - end - end - - grid.with_area(:actions, tag: :div, classes: "hide-when-print") do - render(Primer::Alpha::ActionMenu.new(test_selector: "op-document-types--action-menu")) do |menu| - menu.with_show_button( - icon: "kebab-horizontal", - scheme: :invisible, - "aria-label": I18n.t("documents.document_type_actions") - ) - - build_enumeration_menu(menu) - end - end - end - end %> diff --git a/modules/documents/app/components/documents/admin/document_types/row_component.rb b/modules/documents/app/components/documents/admin/document_types/row_component.rb new file mode 100644 index 000000000000..bf2376aea4f9 --- /dev/null +++ b/modules/documents/app/components/documents/admin/document_types/row_component.rb @@ -0,0 +1,165 @@ +# 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 Documents + module Admin + module DocumentTypes + class RowComponent < OpPrimer::BorderBoxRowComponent + include SortableLists::MoveMenu + + alias_method :document_type, :model + + def row_css_id = "document-type-#{document_type.id}" + + def row_data + { + test_selector: "document-type-row-#{document_type.id}", + controller: "sortable-lists--item", + sortable_lists__item_target: "preview", + sortable_lists__item_id_value: document_type.id, + sortable_lists__item_type_value: DocumentType.model_name.param_key, + sortable_lists__item_label_value: document_type.name + } + end + + def name + flex_layout(align_items: :center) do |flex| + flex.with_column(mr: 2) { drag_handle } + flex.with_column(classes: "ellipsis") { name_link } + flex.with_column(ml: 2) { inactive_label } unless document_type.active? + flex.with_column(ml: 2) { default_label } if document_type.is_default? + end + end + + def documents_count + render(Primer::Beta::Text.new(color: :subtle)) do + document_type.documents_count.to_s + end + end + + def button_links = [action_menu] + + private + + def drag_handle + render( + Primer::OpenProject::DragHandle.new( + data: { sortable_lists__item_target: "handle" }, + classes: "hide-when-print" + ) + ) + end + + def name_link + render( + Primer::Beta::Link.new( + href: edit_admin_settings_document_type_path(document_type), + underline: false + ) + ) do + render(Primer::Beta::Text.new(font_weight: :bold)) { document_type.name } + end + end + + def inactive_label + render(Primer::Beta::Label.new(scheme: :default, test_selector: "label-inactive")) do + I18n.t(:label_inactive) + end + end + + def default_label + render(Primer::Beta::Label.new(scheme: :primary, test_selector: "label-is-default")) do + I18n.t(:label_default) + end + end + + def action_menu + render( + Primer::Alpha::ActionMenu.new( + classes: "hide-when-print" + ) + ) do |menu| + menu.with_show_button( + icon: "kebab-horizontal", + scheme: :invisible, + "aria-label": I18n.t("documents.document_type_actions") + ) + + build_document_type_menu(menu) + end + end + + def build_document_type_menu(menu) + with_item_group(menu) do + edit_document_type(menu) + move_document_type(menu) + end + with_item_group(menu) { delete_document_type(menu) } + end + + def edit_document_type(menu) + menu.with_item( + label: I18n.t(:button_edit), + tag: :a, + href: edit_admin_settings_document_type_path(document_type) + ) do |item| + item.with_leading_visual_icon(icon: :pencil) + end + end + + def move_document_type(menu) + menu.with_item( + component_klass: Primer::Alpha::ActionMenu::SubMenuItem, + label: I18n.t(:button_move), + select_variant: :none, + form_arguments: {}, + data: { sortable_lists__item_target: "moveMenu" } + ) do |submenu| + submenu.with_leading_visual_icon(icon: :"op-arrow-in") + + with_move_items(submenu) + end + end + + def delete_document_type(menu) + menu.with_item( + label: I18n.t(:button_delete), + scheme: :danger, + tag: :a, + content_arguments: { data: { controller: "async-dialog" } }, + href: delete_dialog_admin_settings_document_type_path(document_type) + ) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + end + end + end +end diff --git a/modules/documents/app/components/documents/admin/document_types/item_component.rb b/modules/documents/app/components/documents/admin/document_types/table_component.rb similarity index 50% rename from modules/documents/app/components/documents/admin/document_types/item_component.rb rename to modules/documents/app/components/documents/admin/document_types/table_component.rb index 242dff142aab..046ea5aa4dd0 100644 --- a/modules/documents/app/components/documents/admin/document_types/item_component.rb +++ b/modules/documents/app/components/documents/admin/document_types/table_component.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -# -- copyright +#-- copyright # OpenProject is an open source project management software. -# Copyright (C) 2010-2024 the OpenProject GmbH +# 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. @@ -26,30 +26,44 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # See COPYRIGHT and LICENSE files for more details. -# ++ +#++ module Documents module Admin module DocumentTypes - class ItemComponent < ::Admin::Enumerations::ItemComponent - alias_method :document_type, :enumeration - - def deletion_enumeration(menu) - menu.with_item( - label: I18n.t(:button_delete), - scheme: :danger, - tag: :a, - content_arguments: { - data: { controller: "async-dialog" } - }, - href: delete_dialog_admin_settings_document_type_path(document_type) - ) do |item| - item.with_leading_visual_icon(icon: :trash) - end + class TableComponent < OpPrimer::BorderBoxTableComponent + columns :name, :documents_count + main_column :name + mobile_columns :name + + def row_class = ::Documents::Admin::DocumentTypes::RowComponent + + def has_actions? = true + + def mobile_title = DocumentType.model_name.human(count: :other) + + def container_id = "document-types-table" + + def headers + [ + [:name, { caption: I18n.t("documents.index_page.type") }], + [:documents_count, { caption: I18n.t(:label_documents) }] + ] end - def colored? - false + def blank_title = I18n.t(:no_results_title_text) + + def blank_description = nil + + def container_data + { + controller: "sortable-lists--list", + sortable_lists__list_type_value: DocumentType.model_name.param_key, + sortable_lists__list_accepted_type_value: DocumentType.model_name.param_key, + sortable_lists__list_name_value: DocumentType.model_name.human(count: :other), + # The rows sit in a div, not in the `ul` the list controller looks for by default. + sortable_lists__list_rows_container_element: ":scope > .#{rows_container_class}" + } end end end diff --git a/modules/documents/spec/components/documents/admin/document_types/index_component_spec.rb b/modules/documents/spec/components/documents/admin/document_types/index_component_spec.rb index ca25aedaa117..e5c622ce3b79 100644 --- a/modules/documents/spec/components/documents/admin/document_types/index_component_spec.rb +++ b/modules/documents/spec/components/documents/admin/document_types/index_component_spec.rb @@ -31,29 +31,59 @@ require "spec_helper" RSpec.describe Documents::Admin::DocumentTypes::IndexComponent, type: :component do - let!(:first_type) { create(:document_type, name: "Note") } - let!(:second_type) { create(:document_type, name: "Report") } - subject(:rendered_component) do with_request_url("/admin/settings/document_types") do render_inline(described_class.new(enumerations: DocumentType.reorder(:position))) end end + let!(:note) { create(:document_type, name: "Note") } + let!(:report) { create(:document_type, name: "Report") } + + it "keeps the wrapper the move response morphs" do + expect(rendered_component).to have_css("#documents-admin-document-types-index-component") + end + + # The sortable root stays on the wrapper, which the morph never replaces. it_behaves_like "a sortable-lists root", wrapper_id: "documents-admin-document-types-index-component", move_url_template: "/admin/settings/document_types/{id}/move" + it_behaves_like "no legacy drag-and-drop wiring" + + it "resolves both outlets inside the wrapper", :aggregate_failures do + expect(rendered_component) + .to have_css("#documents-admin-document-types-index-component [data-controller~='sortable-lists--list']", + count: 1) + expect(rendered_component) + .to have_css("#documents-admin-document-types-index-component [data-controller~='sortable-lists--item']", + count: 2) + end + it_behaves_like "a sortable-lists list", list_type: "document_type", - name: DocumentType.model_name.human(count: :other) - it_behaves_like "a Border Box sortable list", row_count: 2 - it_behaves_like "sortable-lists items", list_type: "document_type" do - let(:sortable_records) { [first_type, second_type] } + name: "Document types", + rows_container: ":scope > .op-border-box-table--rows" + + it "leaves the list wiring to the table container" do + expect(rendered_component).to have_css("#documents-admin-document-types-index-component") do |wrapper| + expect(wrapper["data-sortable-lists--list-type-value"]).to be_nil + expect(wrapper["data-sortable-lists--list-rows-container-element"]).to be_nil + end + end + + it "offers adding a document type above the list" do + expect(rendered_component).to have_test_selector("add-document-type-button") + end + + it "renders the document types in the shared table" do + expect(rendered_component).to have_role(:table, accessible_name: "Document types") do |table| + expect(table).to have_selector(:row, "Note") + expect(table).to have_selector(:row, "Report") + end end - it_behaves_like "no legacy drag-and-drop wiring" - it "keeps the two-column grid" do - expect(rendered_component).to have_css(".op-documents-types-list--header", visible: :all) - expect(rendered_component).to have_css(".op-documents-types-list--item", count: 2, visible: :all) + it "drops the bespoke grid and its markup", :aggregate_failures do + expect(rendered_component).to have_no_css(".op-documents-types-list--header") + expect(rendered_component).to have_no_css(".op-documents-types-list--item") end end diff --git a/modules/documents/spec/components/documents/admin/document_types/row_component_spec.rb b/modules/documents/spec/components/documents/admin/document_types/row_component_spec.rb new file mode 100644 index 000000000000..4cc4dd566629 --- /dev/null +++ b/modules/documents/spec/components/documents/admin/document_types/row_component_spec.rb @@ -0,0 +1,120 @@ +# 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 Documents::Admin::DocumentTypes::RowComponent, type: :component do + subject(:rendered_component) do + with_request_url("/admin/settings/document_types") do + render_inline(described_class.new(row: document_type, table:)) + end + end + + let!(:document_type) { create(:document_type, name: "Note") } + let(:table) { Documents::Admin::DocumentTypes::TableComponent.new(rows: DocumentType.reorder(:position)) } + + it "links the name to the edit page" do + expect(rendered_component) + .to have_link("Note", href: "/admin/settings/document_types/#{document_type.id}/edit") + end + + it "shows how many documents use the type in the Documents column" do + create_list(:document, 2, type: document_type) + + expect(rendered_component).to have_css("[role='cell'][aria-colindex='2']", exact_text: "2", normalize_ws: true) + end + + it "targets the drag handle for the item controller" do + expect(rendered_component).to have_css(".DragHandle[data-sortable-lists--item-target~='handle']") + end + + describe "labels beside the name" do + context "when the document type is the default one" do + let!(:document_type) { create(:document_type, name: "Note", is_default: true) } + + it "labels it as the default" do + expect(rendered_component).to have_test_selector("label-is-default", text: I18n.t(:label_default)) + end + end + + context "when the document type is inactive" do + let!(:document_type) { create(:document_type, name: "Note", active: false) } + + it "labels it as inactive" do + expect(rendered_component).to have_test_selector("label-inactive", text: I18n.t(:label_inactive)) + end + end + + context "when the document type is active and not the default one" do + it "carries neither label", :aggregate_failures do + expect(rendered_component).to have_no_test_selector("label-is-default") + expect(rendered_component).to have_no_test_selector("label-inactive") + end + end + end + + describe "the action menu" do + it "keeps the actions out of print, like the drag handle", :aggregate_failures do + expect(rendered_component).to have_css("action-menu.hide-when-print", visible: :all) + expect(rendered_component).to have_css(".DragHandle.hide-when-print", visible: :all) + end + + it "sits behind a button named for the document type" do + expect(rendered_component).to have_button(accessible_name: I18n.t("documents.document_type_actions")) + end + + # The four directions themselves are covered by the SortableLists::MoveMenu spec. + it "renders the Move submenu carrying the moveMenu target with the shared move items" do + expect(rendered_component).to have_css("li[data-sortable-lists--item-target~='moveMenu']", visible: :all) do |item| + expect(item).to have_css("[role='menuitem']", text: I18n.t(:button_move), visible: :all) do |trigger| + expect(rendered_component) + .to have_css("##{trigger['aria-controls']} li[data-sortable-lists--item-target~='moveItem']", count: 4, visible: :all) + end + end + end + + it "renders exactly one divider, so hiding the move submenu leaves a single separator" do + expect(rendered_component).to have_css("li.ActionList-sectionDivider", count: 1, visible: :all) + end + + it "keeps Edit and the async-dialog Delete in the menu, without a legacy move form", :aggregate_failures do + expect(rendered_component) + .to have_link(I18n.t(:button_edit), + href: "/admin/settings/document_types/#{document_type.id}/edit", + visible: :all) + expect(rendered_component) + .to have_link(I18n.t(:button_delete), + href: "/admin/settings/document_types/#{document_type.id}/delete_dialog", + visible: :all) + expect(rendered_component).to have_no_field("move_to", type: :hidden) + expect(rendered_component).to have_no_field("position", type: :hidden) + end + end +end diff --git a/modules/documents/spec/components/documents/admin/document_types/table_component_spec.rb b/modules/documents/spec/components/documents/admin/document_types/table_component_spec.rb new file mode 100644 index 000000000000..9256eba49bd4 --- /dev/null +++ b/modules/documents/spec/components/documents/admin/document_types/table_component_spec.rb @@ -0,0 +1,114 @@ +# 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 Documents::Admin::DocumentTypes::TableComponent, type: :component do + subject(:rendered_component) do + with_request_url("/admin/settings/document_types") do + render_inline(described_class.new(rows: document_types)) + end + end + + let!(:note) { create(:document_type, name: "Note", is_default: true) } + let!(:report) { create(:document_type, name: "Report", active: false) } + let(:document_types) { DocumentType.reorder(:position) } + + context "with document types" do + it_behaves_like "rendering Border Box Grid heading", text: "Type" + it_behaves_like "rendering Border Box Grid heading", text: "Documents" + it_behaves_like "rendering Border Box Grid mobile heading", text: "Document types" + it_behaves_like "rendering Border Box Grid rows", row_count: 2, col_count: 2 + + it "names the table and indexes its columns for assistive technology" do + expect(rendered_component).to have_role(:table, accessible_name: "Document types") do |table| + expect(table["aria-colcount"]).to eq("3") + expect(table).to have_selector(:columnheader, "Type", colindex: 1) + expect(table).to have_selector(:columnheader, "Documents", colindex: 2) + expect(table).to have_css("[role='rowgroup'].op-border-box-table--rows > [role='row']", count: 2) + end + end + + it "renders a row per document type", :aggregate_failures do + expect(rendered_component).to have_selector(:row, "Note") + expect(rendered_component).to have_selector(:row, "Report") + end + + it "hides the documents count on small screens but never the name", :aggregate_failures do + expect(rendered_component) + .to have_css(".op-border-box-grid__row-item.documents_count.op-border-box-grid__row-item--no-mobile", count: 2) + expect(rendered_component).to have_no_css(".op-border-box-grid__row-item.name.op-border-box-grid__row-item--no-mobile") + end + + it_behaves_like "a sortable-lists list", + list_type: "document_type", + name: "Document types", + rows_container: ":scope > .op-border-box-table--rows" + it_behaves_like "a Border Box Table sortable list", row_count: 2 + it_behaves_like "sortable-lists items", list_type: "document_type" do + let(:sortable_records) { [note, report] } + end + + it "leaves the sortable root, its outlets and the move URL to the index wrapper" do + expect(rendered_component).to have_css("[data-controller~='sortable-lists--list']") do |container| + expect(container["data-sortable-lists-move-url-template-value"]).to be_nil + expect(container["data-sortable-lists-sortable-lists--list-outlet"]).to be_nil + expect(container["data-sortable-lists-sortable-lists--item-outlet"]).to be_nil + end + expect(rendered_component).to have_no_css("[data-controller~='sortable-lists']") + end + + it "identifies each row and drags it whole" do + [note, report].each do |document_type| + expect(rendered_component) + .to have_css(".Box-row[data-sortable-lists--item-id-value='#{document_type.id}']") do |row| + expect(row["id"]).to eq("document-type-#{document_type.id}") + expect(row["data-sortable-lists--item-target"]).to eq("preview") + expect(row["data-test-selector"]).to eq("document-type-row-#{document_type.id}") + end + end + end + + it "drops the bespoke grid the table replaces", :aggregate_failures do + expect(rendered_component).to have_no_css(".op-documents-types-list--header") + expect(rendered_component).to have_no_css(".op-documents-types-list--item") + end + end + + context "without document types" do + let(:document_types) { DocumentType.where(id: nil) } + + it "keeps today's blank-state wording and renders no sortable rows", :aggregate_failures do + expect(rendered_component).to have_text(I18n.t(:no_results_title_text)) + expect(rendered_component).to have_no_text(I18n.t(:label_nothing_display)) + expect(rendered_component).to have_no_css("[data-controller~='sortable-lists--item']") + end + end +end diff --git a/modules/documents/spec/features/documents/admin/settings/document_types_spec.rb b/modules/documents/spec/features/documents/admin/settings/document_types_spec.rb index 30d76cec819f..0204bb516bd1 100644 --- a/modules/documents/spec/features/documents/admin/settings/document_types_spec.rb +++ b/modules/documents/spec/features/documents/admin/settings/document_types_spec.rb @@ -32,15 +32,11 @@ RSpec.describe "Document types admin", :js do include Flash::Expectations - include EnumerationAdminHelpers current_user { create(:admin) } - def enumeration_list_selector = "#documents-admin-document-types-index-component" - def enumeration_actions_label = I18n.t("documents.document_type_actions") - - def within_enumeration_item(type, &) - page.within("#documents-admin-document-types-item-component-#{type.id}", &) + def within_document_type_row(type, &) + page.within("#document-type-#{type.id}", &) end context "when managing document types" do @@ -49,7 +45,7 @@ def within_enumeration_item(type, &) it "can be managed (created, updated, deleted)" do visit admin_settings_document_types_path - within_enumeration_item(default_document_type) do + within_document_type_row(default_document_type) do expect(page).to have_content("Note") expect(page).to have_content("Default") end @@ -70,13 +66,13 @@ def within_enumeration_item(type, &) new_document_type = DocumentType.last # The new document type is shown in the list as the default document type - within_enumeration_item(new_document_type) do + within_document_type_row(new_document_type) do expect(page).to have_content("Documentation") expect(page).to have_content("Default") end # Since the new document type is now the default, the former default looses that flag - within_enumeration_item(default_document_type) do + within_document_type_row(default_document_type) do expect(page).to have_content("Note") expect(page).to have_no_content("Default") end @@ -91,7 +87,7 @@ def within_enumeration_item(type, &) expect_and_dismiss_flash(message: "Successful update.") - within_enumeration_item(new_document_type) do + within_document_type_row(new_document_type) do expect(page).to have_content("Report") expect(page).to have_content("Default") end @@ -100,7 +96,7 @@ def within_enumeration_item(type, &) expect(DocumentType).not_to exist(name: "Documentation") # It allows deleting document types - within_enumeration_item(new_document_type) do + within_document_type_row(new_document_type) do click_on accessible_name: "Document type actions" click_on("Delete") end @@ -118,7 +114,7 @@ def within_enumeration_item(type, &) expect(page).to have_no_content("Report") # Since the old default is deleted another is now the default. - within_enumeration_item(default_document_type) do + within_document_type_row(default_document_type) do expect(page).to have_content("Note") expect(page).to have_no_content("Default") end @@ -134,7 +130,7 @@ def within_enumeration_item(type, &) it "reassigns documents when deleting a document type" do visit admin_settings_document_types_path - within_enumeration_item(type_with_documents) do + within_document_type_row(type_with_documents) do click_on accessible_name: "Document type actions" click_on("Delete") end @@ -153,12 +149,12 @@ def within_enumeration_item(type, &) expect(DocumentType).not_to exist(name: "Type with documents") expect(document.reload.type).to eq another_type - within_enumeration_item(another_type) do + within_document_type_row(another_type) do expect(page).to have_test_selector("documents-count", text: "1") end # It allows deleting unused document types - within_enumeration_item(unused_type) do + within_document_type_row(unused_type) do click_on accessible_name: "Document type actions" click_on("Delete") end @@ -176,7 +172,7 @@ def within_enumeration_item(type, &) expect(DocumentType).not_to exist(name: "Unused type") # Last remaining type cannot be deleted - within_enumeration_item(another_type) do + within_document_type_row(another_type) do click_on accessible_name: "Document type actions" click_on("Delete") end @@ -202,18 +198,28 @@ def within_enumeration_item(type, &) alpha.move_to_top end + def document_type_names_in_order + page.all("#documents-admin-document-types-index-component a[href$='/edit']").map(&:text) + end + it "reorders through the move menu" do visit admin_settings_document_types_path - expect_enumeration_order("Alpha", "Beta", "Gamma") + wait_for { document_type_names_in_order }.to eq(%w[Alpha Beta Gamma]) - move_enumeration(gamma, I18n.t(:label_sort_highest)) + within_document_type_row(gamma) do + click_on accessible_name: "Document type actions" + end + click_on I18n.t(:button_move) + click_on I18n.t(:label_sort_highest) - expect_enumeration_move_settled("Gamma", "Alpha", "Beta") + wait_for { document_type_names_in_order }.to eq(%w[Gamma Alpha Beta]) + expect_and_dismiss_flash(message: I18n.t(:enumeration_caption_order_changed)) + expect(page).to have_no_css("[data-sortable-lists-busy]") refresh - expect_enumeration_order("Gamma", "Alpha", "Beta") + wait_for { document_type_names_in_order }.to eq(%w[Gamma Alpha Beta]) end end @@ -223,12 +229,18 @@ def within_enumeration_item(type, &) it "shows a single separator (no duplicate) in the more menu" do visit admin_settings_document_types_path - within_enumeration_menu(only_type) do |menu| - expect(menu).to have_selector(:menuitem, "Edit") - expect(menu).to have_selector(:menuitem, "Delete") - expect(menu).to have_no_selector(:menuitem, I18n.t(:button_move)) - expect(menu).to have_css("li.ActionList-sectionDivider", count: 1) + within_document_type_row(only_type) do + click_on accessible_name: "Document type actions" end + + expect(page).to have_link("Edit") + expect(page).to have_link("Delete") + expect(page).to have_no_text(I18n.t(:button_move)) + expect(page).to have_no_button(I18n.t(:label_sort_highest)) + expect(page).to have_no_button(I18n.t(:label_sort_higher)) + expect(page).to have_no_button(I18n.t(:label_sort_lower)) + expect(page).to have_no_button(I18n.t(:label_sort_lowest)) + expect(page).to have_css("li.ActionList-sectionDivider", count: 1) end end diff --git a/spec/support/shared/components/sortable_lists.rb b/spec/support/shared/components/sortable_lists.rb index d9934a944401..cef4733d2ac1 100644 --- a/spec/support/shared/components/sortable_lists.rb +++ b/spec/support/shared/components/sortable_lists.rb @@ -41,13 +41,15 @@ end end -RSpec.shared_examples "a sortable-lists list" do |list_type:, name:| +# `rows_container` stays nil for lists rendered into the controller's default +# `ul`; tables pass the selector of the rowgroup they render into instead. +RSpec.shared_examples "a sortable-lists list" do |list_type:, name:, rows_container: nil| it "wires a single list of type #{list_type}" do expect(rendered_component).to have_css("[data-controller~='sortable-lists--list']", count: 1) do |list| expect(list["data-sortable-lists--list-type-value"]).to eq(list_type) expect(list["data-sortable-lists--list-accepted-type-value"]).to eq(list_type) expect(list["data-sortable-lists--list-name-value"]).to eq(name) - expect(list["data-sortable-lists--list-rows-container-element"]).to be_nil + expect(list["data-sortable-lists--list-rows-container-element"]).to eq(rows_container) end end end @@ -61,12 +63,23 @@ end end +# The Border Box Table renders its rows into a `div` rowgroup, which the list +# controller only reaches through the rows-container selector. +RSpec.shared_examples "a Border Box Table sortable list" do |row_count:| + it "keeps #{row_count} rows in the table's rowgroup rather than a list", :aggregate_failures do + expect(rendered_component) + .to have_css("[data-controller~='sortable-lists--list'] > .op-border-box-table--rows[role='rowgroup'] > .Box-row", + count: row_count) + expect(rendered_component).to have_no_css("[data-controller~='sortable-lists--list'] > ul") + end +end + # Consumers define `sortable_records` with a `let` in the inclusion block. RSpec.shared_examples "sortable-lists items" do |list_type:| it "wires every row as a sortable item of type #{list_type}" do sortable_records.each do |record| expect(rendered_component) - .to have_css("li.Box-row[data-sortable-lists--item-id-value='#{record.id}']") do |row| + .to have_css(".Box-row[data-sortable-lists--item-id-value='#{record.id}']") do |row| expect(row["data-controller"]).to eq("sortable-lists--item") expect(row["data-sortable-lists--item-type-value"]).to eq(list_type) expect(row["data-sortable-lists--item-label-value"]).to eq(record.name) diff --git a/spec/support/shared/enumeration_admin_helpers.rb b/spec/support/shared/enumeration_admin_helpers.rb index 73904fd1caa0..855b6e9e45ac 100644 --- a/spec/support/shared/enumeration_admin_helpers.rb +++ b/spec/support/shared/enumeration_admin_helpers.rb @@ -28,8 +28,8 @@ # See COPYRIGHT and LICENSE files for more details. #++ -# Menu and ordering helpers for the enumeration admin lists (priorities, -# time entry activities, document types). Included per feature spec. +# Menu and ordering helpers for the enumeration admin Border Box lists +# (priorities, time entry activities). Included per feature spec. module EnumerationAdminHelpers def within_enumeration_list(&) page.within(enumeration_list_selector, &) From 95c6808883d93eb4d54d4e0051ed2ae29fe81f70 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 18 Sep 2026 21:14:44 +0100 Subject: [PATCH 2/8] [DREAM-840] Cover document type table in UI The table changes the drag topology: the row is its own preview and the rows container is selected explicitly, while the list and item elements are morphed by the response to every move. A second drag after a completed morph is what proves the re-registration still holds. https://community.openproject.org/wp/DREAM-840 --- .../admin/settings/document_types_spec.rb | 151 +++++++++++------- .../additional_accessible_selectors.rb | 12 ++ .../shared/enumeration_admin_helpers.rb | 18 ++- 3 files changed, 113 insertions(+), 68 deletions(-) diff --git a/modules/documents/spec/features/documents/admin/settings/document_types_spec.rb b/modules/documents/spec/features/documents/admin/settings/document_types_spec.rb index 0204bb516bd1..b54bca4827e9 100644 --- a/modules/documents/spec/features/documents/admin/settings/document_types_spec.rb +++ b/modules/documents/spec/features/documents/admin/settings/document_types_spec.rb @@ -32,11 +32,28 @@ RSpec.describe "Document types admin", :js do include Flash::Expectations + include EnumerationAdminHelpers current_user { create(:admin) } - def within_document_type_row(type, &) - page.within("#document-type-#{type.id}", &) + def enumeration_list_selector = "#document-types-table > .op-border-box-table--rows" + def enumeration_item_selector = :row + def enumeration_actions_label = I18n.t("documents.document_type_actions") + + def within_document_type_row(document_type, &) + within_enumeration_list { within(:row, document_type.name, &) } + end + + def drag_document_type(document_type, after:) + handle = enumeration_drag_handle(document_type) + target = enumeration_row(after) + offset_y = (target.native.rect.height / 2) - [6, target.native.rect.height / 4].min + + perform_native_drag(source: handle, target:, offset_y: offset_y.round) + + # Assert Pragmatic DnD tore down its own honey-pot overlay, so a regression + # leaving it stuck is caught here rather than as an unrelated click failure. + expect(page).to have_no_css("[data-pdnd-honey-pot]", wait: 2, visible: :all) end context "when managing document types" do @@ -46,8 +63,8 @@ def within_document_type_row(type, &) visit admin_settings_document_types_path within_document_type_row(default_document_type) do - expect(page).to have_content("Note") - expect(page).to have_content("Default") + expect(page).to have_text("Note") + expect(page).to have_text("Default") end within_test_selector("admin-document-types-subheader") do @@ -67,14 +84,14 @@ def within_document_type_row(type, &) # The new document type is shown in the list as the default document type within_document_type_row(new_document_type) do - expect(page).to have_content("Documentation") - expect(page).to have_content("Default") + expect(page).to have_text("Documentation") + expect(page).to have_text("Default") end # Since the new document type is now the default, the former default looses that flag within_document_type_row(default_document_type) do - expect(page).to have_content("Note") - expect(page).to have_no_content("Default") + expect(page).to have_text("Note") + expect(page).to have_no_text("Default") end click_link "Documentation" @@ -87,36 +104,35 @@ def within_document_type_row(type, &) expect_and_dismiss_flash(message: "Successful update.") - within_document_type_row(new_document_type) do - expect(page).to have_content("Report") - expect(page).to have_content("Default") + within_document_type_row(new_document_type.reload) do + expect(page).to have_text("Report") + expect(page).to have_text("Default") end expect(DocumentType).to exist(name: "Report") expect(DocumentType).not_to exist(name: "Documentation") # It allows deleting document types - within_document_type_row(new_document_type) do - click_on accessible_name: "Document type actions" - click_on("Delete") + within_enumeration_menu(new_document_type) do |menu| + menu.find(:menuitem, I18n.t(:button_delete)).click end within_dialog("Delete document type") do expect(page).to have_heading "Delete this document type?" - expect(page).to have_content 'The type "Report" is currently unused. ' \ - "Deleting this type will have no effect on existing documents." + expect(page).to have_text 'The type "Report" is currently unused. ' \ + "Deleting this type will have no effect on existing documents." click_on "Delete permanently" end expect_and_dismiss_flash(message: "Successful deletion.") - expect(page).to have_no_content("Report") + expect(page).to have_no_text("Report") # Since the old default is deleted another is now the default. within_document_type_row(default_document_type) do - expect(page).to have_content("Note") - expect(page).to have_no_content("Default") + expect(page).to have_text("Note") + expect(page).to have_no_text("Default") end end end @@ -130,15 +146,14 @@ def within_document_type_row(type, &) it "reassigns documents when deleting a document type" do visit admin_settings_document_types_path - within_document_type_row(type_with_documents) do - click_on accessible_name: "Document type actions" - click_on("Delete") + within_enumeration_menu(type_with_documents) do |menu| + menu.find(:menuitem, I18n.t(:button_delete)).click end within_dialog("Delete document type") do expect(page).to have_heading "Delete this document type?" - expect(page).to have_content 'The type "Type with documents" is currently being used in 1 document. ' \ - "Please select which type to reassign them to." + expect(page).to have_text 'The type "Type with documents" is currently being used in 1 document. ' \ + "Please select which type to reassign them to." select another_type.name, from: "Reassign documents to" click_on "Delete permanently" @@ -150,19 +165,18 @@ def within_document_type_row(type, &) expect(document.reload.type).to eq another_type within_document_type_row(another_type) do - expect(page).to have_test_selector("documents-count", text: "1") + expect(page).to have_css("[role='cell'][aria-colindex='2']", exact_text: "1", normalize_ws: true) end # It allows deleting unused document types - within_document_type_row(unused_type) do - click_on accessible_name: "Document type actions" - click_on("Delete") + within_enumeration_menu(unused_type) do |menu| + menu.find(:menuitem, I18n.t(:button_delete)).click end within_dialog("Delete document type") do expect(page).to have_heading "Delete this document type?" - expect(page).to have_content 'The type "Unused type" is currently unused. ' \ - "Deleting this type will have no effect on existing documents." + expect(page).to have_text 'The type "Unused type" is currently unused. ' \ + "Deleting this type will have no effect on existing documents." click_on "Delete permanently" end @@ -172,15 +186,14 @@ def within_document_type_row(type, &) expect(DocumentType).not_to exist(name: "Unused type") # Last remaining type cannot be deleted - within_document_type_row(another_type) do - click_on accessible_name: "Document type actions" - click_on("Delete") + within_enumeration_menu(another_type) do |menu| + menu.find(:menuitem, I18n.t(:button_delete)).click end within_dialog("Cannot delete document type") do expect(page).to have_heading "Cannot delete the last document type" - expect(page).to have_content "There must always be at least one document type configured. " \ - "Create another one first if you want to delete this one." + expect(page).to have_text "There must always be at least one document type configured. " \ + "Create another one first if you want to delete this one." click_on "Close" end @@ -198,49 +211,65 @@ def within_document_type_row(type, &) alpha.move_to_top end - def document_type_names_in_order - page.all("#documents-admin-document-types-index-component a[href$='/edit']").map(&:text) + # The moved row's menu is reopened after the morph: only a refreshed menu + # hides the directions that stopped being available. + it "reorders through the move menu twice across a morph" do + visit admin_settings_document_types_path + + expect_enumeration_order("Alpha", "Beta", "Gamma") + + move_enumeration(gamma, I18n.t(:label_sort_highest)) + + expect_enumeration_move_settled("Gamma", "Alpha", "Beta") + + within_enumeration_move_submenu(gamma) do |submenu| + expect(submenu).to have_no_selector(:menuitem, I18n.t(:label_sort_highest)) + expect(submenu).to have_no_selector(:menuitem, I18n.t(:label_sort_higher)) + expect(submenu).to have_selector(:menuitem, I18n.t(:label_sort_lower)) + submenu.find(:menuitem, I18n.t(:label_sort_lowest)).click + end + + expect_enumeration_move_settled("Alpha", "Beta", "Gamma") + + refresh + + expect_enumeration_order("Alpha", "Beta", "Gamma") end - it "reorders through the move menu" do + # The second drag runs without a reload on purpose: the sortable root + # re-registers Pragmatic's drop targets after a morph, and only a drag that + # follows a completed morph exercises that repair. + it "reorders by dragging twice across a morph", :selenium do visit admin_settings_document_types_path - wait_for { document_type_names_in_order }.to eq(%w[Alpha Beta Gamma]) + expect_enumeration_order("Alpha", "Beta", "Gamma") - within_document_type_row(gamma) do - click_on accessible_name: "Document type actions" - end - click_on I18n.t(:button_move) - click_on I18n.t(:label_sort_highest) + drag_document_type(alpha, after: beta) + + expect_enumeration_move_settled("Beta", "Alpha", "Gamma") - wait_for { document_type_names_in_order }.to eq(%w[Gamma Alpha Beta]) - expect_and_dismiss_flash(message: I18n.t(:enumeration_caption_order_changed)) - expect(page).to have_no_css("[data-sortable-lists-busy]") + drag_document_type(beta, after: gamma) + + expect_enumeration_move_settled("Alpha", "Gamma", "Beta") refresh - wait_for { document_type_names_in_order }.to eq(%w[Gamma Alpha Beta]) + expect_enumeration_order("Alpha", "Gamma", "Beta") end end context "with a single document type" do let!(:only_type) { create(:document_type, name: "Only type") } - it "shows a single separator (no duplicate) in the more menu" do + it "hides the Move submenu and renders one separator" do visit admin_settings_document_types_path - within_document_type_row(only_type) do - click_on accessible_name: "Document type actions" + within_enumeration_menu(only_type) do |menu| + expect(menu).to have_selector(:menuitem, I18n.t(:button_edit)) + expect(menu).to have_selector(:menuitem, I18n.t(:button_delete)) + expect(menu).to have_no_selector(:menuitem, I18n.t(:button_move)) + expect(menu).to have_css("li.ActionList-sectionDivider", count: 1) end - - expect(page).to have_link("Edit") - expect(page).to have_link("Delete") - expect(page).to have_no_text(I18n.t(:button_move)) - expect(page).to have_no_button(I18n.t(:label_sort_highest)) - expect(page).to have_no_button(I18n.t(:label_sort_higher)) - expect(page).to have_no_button(I18n.t(:label_sort_lower)) - expect(page).to have_no_button(I18n.t(:label_sort_lowest)) - expect(page).to have_css("li.ActionList-sectionDivider", count: 1) end end @@ -250,7 +279,7 @@ def document_type_names_in_order it "is not accessible" do visit admin_settings_document_types_path - expect(page).to have_content("You are not authorized to access this page.") + expect(page).to have_text("You are not authorized to access this page.") end end end diff --git a/spec/support/capybara/additional_accessible_selectors.rb b/spec/support/capybara/additional_accessible_selectors.rb index 6475ee1916d0..0b512b7b1b61 100644 --- a/spec/support/capybara/additional_accessible_selectors.rb +++ b/spec/support/capybara/additional_accessible_selectors.rb @@ -44,6 +44,18 @@ filter_set(:capybara_accessible_selectors, %i[aria described_by]) end +# Border Box Tables render `role="row"` inside a `div` rowgroup with no +# `aria-rowindex`, which the gem's `rowindex:` filter cannot resolve. +Capybara.modify_selector(:row) do + expression_filter(:position, skip_if: nil) do |xpath, position| + xpath[position] + end + + describe_expression_filters do |position: nil, **| + position ? " at position #{position}" : "" + end +end + module Capybara module RSpecMatchers # Following finder methods are defined: diff --git a/spec/support/shared/enumeration_admin_helpers.rb b/spec/support/shared/enumeration_admin_helpers.rb index 855b6e9e45ac..599e847f57cd 100644 --- a/spec/support/shared/enumeration_admin_helpers.rb +++ b/spec/support/shared/enumeration_admin_helpers.rb @@ -28,18 +28,22 @@ # See COPYRIGHT and LICENSE files for more details. #++ -# Menu and ordering helpers for the enumeration admin Border Box lists -# (priorities, time entry activities). Included per feature spec. +# Menu and ordering helpers for the enumeration admin lists (priorities, +# time entry activities, document types). Included per feature spec. +# Consumers define `enumeration_list_selector` and `enumeration_actions_label`; +# tables override `enumeration_item_selector` with `:row`. module EnumerationAdminHelpers + def enumeration_item_selector = :list_item + def within_enumeration_list(&) page.within(enumeration_list_selector, &) end def expect_enumeration_order(*names) within_enumeration_list do - expect(page).to have_list_item(count: names.size) + expect(page).to have_selector(enumeration_item_selector, count: names.size) names.each_with_index do |name, index| - expect(page).to have_list_item(name, position: index + 1) + expect(page).to have_selector(enumeration_item_selector, name, position: index + 1) end end end @@ -52,7 +56,7 @@ def expect_enumeration_move_settled(*names) def within_enumeration_menu(record, &) within_enumeration_list do - within(:list_item, record.name) do + within(enumeration_item_selector, record.name) do button = find(:button, accessible_name: enumeration_actions_label) within(open_controlled_menu(button), &) end @@ -73,13 +77,13 @@ def move_enumeration(record, direction_label) def enumeration_drag_handle(record) within_enumeration_list do - find(:list_item, record.name) + find(enumeration_item_selector, record.name) .find(:button, accessible_name: I18n.t("drag_handle.button_drag")) end end def enumeration_row(record) - within_enumeration_list { find(:list_item, record.name) } + within_enumeration_list { find(enumeration_item_selector, record.name) } end private From 2c2937898e58d491d13f58b2292af7a2b7761713 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 24 Sep 2026 15:27:43 +0100 Subject: [PATCH 3/8] [DREAM-840] Drive features through page objects Replaces the EnumerationAdminHelpers mixin with a page object, Pages::Admin::EnumerationList, that priorities, time entry activities and document types subclass with their path, list selector, row selector and action button label. The specs stop redefining helper methods per file, and the drag helper lives in one place. https://community.openproject.org/wp/DREAM-840 --- .../settings/time_entry_activities_spec.rb | 16 ++- .../pages/admin/time_entry_activities.rb | 43 +++++++ .../admin/settings/document_types_spec.rb | 93 ++++++--------- .../support/pages/admin/document_types.rb | 45 +++++++ .../settings/work_package_priorities_spec.rb | 78 +++++------- spec/support/pages/admin/enumeration_list.rb | 112 ++++++++++++++++++ .../pages/admin/work_package_priorities.rb | 43 +++++++ .../shared/enumeration_admin_helpers.rb | 95 --------------- 8 files changed, 316 insertions(+), 209 deletions(-) create mode 100644 modules/costs/spec/support/pages/admin/time_entry_activities.rb create mode 100644 modules/documents/spec/support/pages/admin/document_types.rb create mode 100644 spec/support/pages/admin/enumeration_list.rb create mode 100644 spec/support/pages/admin/work_package_priorities.rb delete mode 100644 spec/support/shared/enumeration_admin_helpers.rb diff --git a/modules/costs/spec/features/admin/settings/time_entry_activities_spec.rb b/modules/costs/spec/features/admin/settings/time_entry_activities_spec.rb index dd9059e74012..fe5ec83b4d3b 100644 --- a/modules/costs/spec/features/admin/settings/time_entry_activities_spec.rb +++ b/modules/costs/spec/features/admin/settings/time_entry_activities_spec.rb @@ -29,16 +29,17 @@ #++ require "spec_helper" +require_relative "../../../support/pages/admin/time_entry_activities" RSpec.describe "Time entry activities admin", :js do include Flash::Expectations - include EnumerationAdminHelpers current_user { create(:admin) } let!(:alpha) { create(:time_entry_activity, name: "Alpha") } let!(:beta) { create(:time_entry_activity, name: "Beta") } let!(:gamma) { create(:time_entry_activity, name: "Gamma") } + let(:list_page) { Pages::Admin::TimeEntryActivities.new } before do gamma.move_to_top @@ -46,20 +47,17 @@ alpha.move_to_top end - def enumeration_list_selector = "#admin-enumerations-index-component" - def enumeration_actions_label = "Actions" - it "reorders through the move menu" do - visit admin_settings_time_entry_activities_path + list_page.visit! - expect_enumeration_order("Alpha", "Beta", "Gamma") + list_page.expect_order("Alpha", "Beta", "Gamma") - move_enumeration(gamma, I18n.t(:label_sort_highest)) + list_page.move(gamma, I18n.t(:label_sort_highest)) - expect_enumeration_move_settled("Gamma", "Alpha", "Beta") + list_page.expect_move_settled("Gamma", "Alpha", "Beta") refresh - expect_enumeration_order("Gamma", "Alpha", "Beta") + list_page.expect_order("Gamma", "Alpha", "Beta") end end diff --git a/modules/costs/spec/support/pages/admin/time_entry_activities.rb b/modules/costs/spec/support/pages/admin/time_entry_activities.rb new file mode 100644 index 000000000000..ca88f4c2d29f --- /dev/null +++ b/modules/costs/spec/support/pages/admin/time_entry_activities.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "support/pages/admin/enumeration_list" + +module Pages + module Admin + class TimeEntryActivities < EnumerationList + def path = admin_settings_time_entry_activities_path + + def list_selector = "#admin-enumerations-index-component" + + def actions_label = "Actions" + end + end +end diff --git a/modules/documents/spec/features/documents/admin/settings/document_types_spec.rb b/modules/documents/spec/features/documents/admin/settings/document_types_spec.rb index b54bca4827e9..42d0ab557692 100644 --- a/modules/documents/spec/features/documents/admin/settings/document_types_spec.rb +++ b/modules/documents/spec/features/documents/admin/settings/document_types_spec.rb @@ -29,40 +29,21 @@ #++ require "spec_helper" +require_relative "../../../../support/pages/admin/document_types" RSpec.describe "Document types admin", :js do include Flash::Expectations - include EnumerationAdminHelpers current_user { create(:admin) } - - def enumeration_list_selector = "#document-types-table > .op-border-box-table--rows" - def enumeration_item_selector = :row - def enumeration_actions_label = I18n.t("documents.document_type_actions") - - def within_document_type_row(document_type, &) - within_enumeration_list { within(:row, document_type.name, &) } - end - - def drag_document_type(document_type, after:) - handle = enumeration_drag_handle(document_type) - target = enumeration_row(after) - offset_y = (target.native.rect.height / 2) - [6, target.native.rect.height / 4].min - - perform_native_drag(source: handle, target:, offset_y: offset_y.round) - - # Assert Pragmatic DnD tore down its own honey-pot overlay, so a regression - # leaving it stuck is caught here rather than as an unrelated click failure. - expect(page).to have_no_css("[data-pdnd-honey-pot]", wait: 2, visible: :all) - end + let(:list_page) { Pages::Admin::DocumentTypes.new } context "when managing document types" do let!(:default_document_type) { create(:document_type, is_default: true, name: "Note") } it "can be managed (created, updated, deleted)" do - visit admin_settings_document_types_path + list_page.visit! - within_document_type_row(default_document_type) do + list_page.within_row(default_document_type) do expect(page).to have_text("Note") expect(page).to have_text("Default") end @@ -83,13 +64,13 @@ def drag_document_type(document_type, after:) new_document_type = DocumentType.last # The new document type is shown in the list as the default document type - within_document_type_row(new_document_type) do + list_page.within_row(new_document_type) do expect(page).to have_text("Documentation") expect(page).to have_text("Default") end # Since the new document type is now the default, the former default looses that flag - within_document_type_row(default_document_type) do + list_page.within_row(default_document_type) do expect(page).to have_text("Note") expect(page).to have_no_text("Default") end @@ -104,7 +85,7 @@ def drag_document_type(document_type, after:) expect_and_dismiss_flash(message: "Successful update.") - within_document_type_row(new_document_type.reload) do + list_page.within_row(new_document_type.reload) do expect(page).to have_text("Report") expect(page).to have_text("Default") end @@ -113,8 +94,8 @@ def drag_document_type(document_type, after:) expect(DocumentType).not_to exist(name: "Documentation") # It allows deleting document types - within_enumeration_menu(new_document_type) do |menu| - menu.find(:menuitem, I18n.t(:button_delete)).click + list_page.within_menu(new_document_type) do |menu| + menu.find(:menuitem, "Delete").click end within_dialog("Delete document type") do @@ -130,7 +111,7 @@ def drag_document_type(document_type, after:) expect(page).to have_no_text("Report") # Since the old default is deleted another is now the default. - within_document_type_row(default_document_type) do + list_page.within_row(default_document_type) do expect(page).to have_text("Note") expect(page).to have_no_text("Default") end @@ -144,10 +125,10 @@ def drag_document_type(document_type, after:) let!(:document) { create(:document, type: type_with_documents) } it "reassigns documents when deleting a document type" do - visit admin_settings_document_types_path + list_page.visit! - within_enumeration_menu(type_with_documents) do |menu| - menu.find(:menuitem, I18n.t(:button_delete)).click + list_page.within_menu(type_with_documents) do |menu| + menu.find(:menuitem, "Delete").click end within_dialog("Delete document type") do @@ -164,13 +145,13 @@ def drag_document_type(document_type, after:) expect(DocumentType).not_to exist(name: "Type with documents") expect(document.reload.type).to eq another_type - within_document_type_row(another_type) do + list_page.within_row(another_type) do expect(page).to have_css("[role='cell'][aria-colindex='2']", exact_text: "1", normalize_ws: true) end # It allows deleting unused document types - within_enumeration_menu(unused_type) do |menu| - menu.find(:menuitem, I18n.t(:button_delete)).click + list_page.within_menu(unused_type) do |menu| + menu.find(:menuitem, "Delete").click end within_dialog("Delete document type") do @@ -186,8 +167,8 @@ def drag_document_type(document_type, after:) expect(DocumentType).not_to exist(name: "Unused type") # Last remaining type cannot be deleted - within_enumeration_menu(another_type) do |menu| - menu.find(:menuitem, I18n.t(:button_delete)).click + list_page.within_menu(another_type) do |menu| + menu.find(:menuitem, "Delete").click end within_dialog("Cannot delete document type") do @@ -214,47 +195,47 @@ def drag_document_type(document_type, after:) # The moved row's menu is reopened after the morph: only a refreshed menu # hides the directions that stopped being available. it "reorders through the move menu twice across a morph" do - visit admin_settings_document_types_path + list_page.visit! - expect_enumeration_order("Alpha", "Beta", "Gamma") + list_page.expect_order("Alpha", "Beta", "Gamma") - move_enumeration(gamma, I18n.t(:label_sort_highest)) + list_page.move(gamma, I18n.t(:label_sort_highest)) - expect_enumeration_move_settled("Gamma", "Alpha", "Beta") + list_page.expect_move_settled("Gamma", "Alpha", "Beta") - within_enumeration_move_submenu(gamma) do |submenu| + list_page.within_move_submenu(gamma) do |submenu| expect(submenu).to have_no_selector(:menuitem, I18n.t(:label_sort_highest)) expect(submenu).to have_no_selector(:menuitem, I18n.t(:label_sort_higher)) expect(submenu).to have_selector(:menuitem, I18n.t(:label_sort_lower)) submenu.find(:menuitem, I18n.t(:label_sort_lowest)).click end - expect_enumeration_move_settled("Alpha", "Beta", "Gamma") + list_page.expect_move_settled("Alpha", "Beta", "Gamma") refresh - expect_enumeration_order("Alpha", "Beta", "Gamma") + list_page.expect_order("Alpha", "Beta", "Gamma") end # The second drag runs without a reload on purpose: the sortable root # re-registers Pragmatic's drop targets after a morph, and only a drag that # follows a completed morph exercises that repair. it "reorders by dragging twice across a morph", :selenium do - visit admin_settings_document_types_path + list_page.visit! - expect_enumeration_order("Alpha", "Beta", "Gamma") + list_page.expect_order("Alpha", "Beta", "Gamma") - drag_document_type(alpha, after: beta) + list_page.drag(alpha, after: beta) - expect_enumeration_move_settled("Beta", "Alpha", "Gamma") + list_page.expect_move_settled("Beta", "Alpha", "Gamma") - drag_document_type(beta, after: gamma) + list_page.drag(beta, after: gamma) - expect_enumeration_move_settled("Alpha", "Gamma", "Beta") + list_page.expect_move_settled("Alpha", "Gamma", "Beta") refresh - expect_enumeration_order("Alpha", "Gamma", "Beta") + list_page.expect_order("Alpha", "Gamma", "Beta") end end @@ -262,11 +243,11 @@ def drag_document_type(document_type, after:) let!(:only_type) { create(:document_type, name: "Only type") } it "hides the Move submenu and renders one separator" do - visit admin_settings_document_types_path + list_page.visit! - within_enumeration_menu(only_type) do |menu| - expect(menu).to have_selector(:menuitem, I18n.t(:button_edit)) - expect(menu).to have_selector(:menuitem, I18n.t(:button_delete)) + list_page.within_menu(only_type) do |menu| + expect(menu).to have_selector(:menuitem, "Edit") + expect(menu).to have_selector(:menuitem, "Delete") expect(menu).to have_no_selector(:menuitem, I18n.t(:button_move)) expect(menu).to have_css("li.ActionList-sectionDivider", count: 1) end @@ -277,7 +258,7 @@ def drag_document_type(document_type, after:) current_user { create(:user) } it "is not accessible" do - visit admin_settings_document_types_path + list_page.visit! expect(page).to have_text("You are not authorized to access this page.") end diff --git a/modules/documents/spec/support/pages/admin/document_types.rb b/modules/documents/spec/support/pages/admin/document_types.rb new file mode 100644 index 000000000000..c94c378c18c3 --- /dev/null +++ b/modules/documents/spec/support/pages/admin/document_types.rb @@ -0,0 +1,45 @@ +# 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 "support/pages/admin/enumeration_list" + +module Pages + module Admin + class DocumentTypes < EnumerationList + def path = admin_settings_document_types_path + + def list_selector = "#document-types-table > .op-border-box-table--rows" + + def item_selector = :row + + def actions_label = I18n.t("documents.document_type_actions") + end + end +end diff --git a/spec/features/admin/settings/work_package_priorities_spec.rb b/spec/features/admin/settings/work_package_priorities_spec.rb index 1ddf58888687..544e1d910437 100644 --- a/spec/features/admin/settings/work_package_priorities_spec.rb +++ b/spec/features/admin/settings/work_package_priorities_spec.rb @@ -32,34 +32,15 @@ RSpec.describe "Work package priorities", :js do include Flash::Expectations - include EnumerationAdminHelpers current_user { create(:admin) } let!(:default_priority) { create(:issue_priority, is_default: true, name: "Normal") } - - def enumeration_list_selector = "#admin-enumerations-index-component" - def enumeration_actions_label = "Actions" - - def within_enumeration_item(priority, &) - page.within("#admin-enumerations-item-component-#{priority.id}", &) - end - - def drag_priority(priority, after:) - handle = enumeration_drag_handle(priority) - target = enumeration_row(after) - offset_y = (target.native.rect.height / 2) - [6, target.native.rect.height / 4].min - - perform_native_drag(source: handle, target:, offset_y: offset_y.round) - - # Assert Pragmatic DnD tore down its own honey-pot overlay, so a regression - # leaving it stuck is caught here rather than as an unrelated click failure. - expect(page).to have_no_css("[data-pdnd-honey-pot]", wait: 2, visible: :all) - end + let(:list_page) { Pages::Admin::WorkPackagePriorities.new } it "can be managed (created, updated, deleted)" do - visit admin_settings_work_package_priorities_path + list_page.visit! - within_enumeration_item(default_priority) do + list_page.within_row(default_priority) do expect(page).to have_content("Normal") expect(page).to have_content("Default") end @@ -78,13 +59,13 @@ def drag_priority(priority, after:) new_priority = IssuePriority.last # The new priority is shown in the list as the default priority - within_enumeration_item(new_priority) do + list_page.within_row(new_priority) do expect(page).to have_content("Immediate") expect(page).to have_content("Default") end # Since the new priority is now the default, the former default looses that flag - within_enumeration_item(default_priority) do + list_page.within_row(default_priority) do expect(page).to have_content("Normal") expect(page).to have_no_content("Default") end @@ -97,7 +78,7 @@ def drag_priority(priority, after:) expect_and_dismiss_flash(message: "Successful update.") - within_enumeration_item(new_priority) do + list_page.within_row(new_priority.reload) do expect(page).to have_content("Urgent") expect(page).to have_content("Default") end @@ -106,9 +87,8 @@ def drag_priority(priority, after:) expect(IssuePriority).not_to exist(name: "Immediate") # It allows deleting priorities - within_enumeration_item(new_priority) do - find(test_selector("op-enumeration--action-menu")).click - click_button("Delete") + list_page.within_menu(new_priority) do |menu| + menu.find(:menuitem, "Delete").click end expect_and_dismiss_flash(message: "Successful deletion.") @@ -116,7 +96,7 @@ def drag_priority(priority, after:) expect(page).to have_no_content("Urgent") # Since the old default is deleted another is now the default. - within_enumeration_item(default_priority) do + list_page.within_row(default_priority) do expect(page).to have_content("Normal") expect(page).to have_no_content("Default") end @@ -137,68 +117,68 @@ def drag_priority(priority, after:) # re-registers Pragmatic's drop targets after a morph, and only a drag that # follows a completed morph exercises that repair. it "reorders by dragging twice across a morph", :selenium do - visit admin_settings_work_package_priorities_path + list_page.visit! - expect_enumeration_order("Alpha", "Beta", "Gamma", "Normal") + list_page.expect_order("Alpha", "Beta", "Gamma", "Normal") - drag_priority(alpha, after: beta) + list_page.drag(alpha, after: beta) - expect_enumeration_move_settled("Beta", "Alpha", "Gamma", "Normal") + list_page.expect_move_settled("Beta", "Alpha", "Gamma", "Normal") - drag_priority(beta, after: gamma) + list_page.drag(beta, after: gamma) - expect_enumeration_move_settled("Alpha", "Gamma", "Beta", "Normal") + list_page.expect_move_settled("Alpha", "Gamma", "Beta", "Normal") refresh - expect_enumeration_order("Alpha", "Gamma", "Beta", "Normal") + list_page.expect_order("Alpha", "Gamma", "Beta", "Normal") end # The moved item's menu is reopened after the morph: only a refreshed menu # hides the directions that stopped being available. it "reorders through the move menu twice across a morph" do - visit admin_settings_work_package_priorities_path + list_page.visit! - expect_enumeration_order("Alpha", "Beta", "Gamma", "Normal") + list_page.expect_order("Alpha", "Beta", "Gamma", "Normal") - move_enumeration(gamma, I18n.t(:label_sort_highest)) + list_page.move(gamma, I18n.t(:label_sort_highest)) - expect_enumeration_move_settled("Gamma", "Alpha", "Beta", "Normal") + list_page.expect_move_settled("Gamma", "Alpha", "Beta", "Normal") - within_enumeration_move_submenu(gamma) do |submenu| + list_page.within_move_submenu(gamma) do |submenu| expect(submenu).to have_no_selector(:menuitem, I18n.t(:label_sort_highest)) expect(submenu).to have_no_selector(:menuitem, I18n.t(:label_sort_higher)) expect(submenu).to have_selector(:menuitem, I18n.t(:label_sort_lower)) submenu.find(:menuitem, I18n.t(:label_sort_lowest)).click end - expect_enumeration_move_settled("Alpha", "Beta", "Normal", "Gamma") + list_page.expect_move_settled("Alpha", "Beta", "Normal", "Gamma") refresh - expect_enumeration_order("Alpha", "Beta", "Normal", "Gamma") + list_page.expect_order("Alpha", "Beta", "Normal", "Gamma") end it "rolls back and reports a move whose anchor no longer exists" do - visit admin_settings_work_package_priorities_path + list_page.visit! - expect_enumeration_order("Alpha", "Beta", "Gamma", "Normal") + list_page.expect_order("Alpha", "Beta", "Gamma", "Normal") beta.destroy - move_enumeration(alpha, I18n.t(:label_sort_lower)) + list_page.move(alpha, I18n.t(:label_sort_lower)) expect_flash(type: :error, message: I18n.t(:error_invalid_list_move_anchor)) expect(page).to have_no_css("[data-sortable-lists-busy]") - expect_enumeration_order("Alpha", "Beta", "Gamma", "Normal") + list_page.expect_order("Alpha", "Beta", "Gamma", "Normal") end end context "with a single priority" do it "hides the Move submenu and renders one separator" do - visit admin_settings_work_package_priorities_path + list_page.visit! - within_enumeration_menu(default_priority) do |menu| + list_page.within_menu(default_priority) do |menu| expect(menu).to have_selector(:menuitem, I18n.t(:button_edit)) expect(menu).to have_selector(:menuitem, I18n.t(:button_delete)) expect(menu).to have_no_selector(:menuitem, I18n.t(:button_move)) diff --git a/spec/support/pages/admin/enumeration_list.rb b/spec/support/pages/admin/enumeration_list.rb new file mode 100644 index 000000000000..6f6eded74096 --- /dev/null +++ b/spec/support/pages/admin/enumeration_list.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 "support/pages/page" + +module Pages + module Admin + # Drives the enumeration admin lists (priorities, time entry activities, + # document types). Subclasses define `path`, `list_selector` and + # `actions_label`, and override `item_selector` for tables; records are + # addressed by name. + class EnumerationList < ::Pages::Page + def item_selector = :list_item + + def within_list(&) + within(list_selector, &) + end + + def within_row(record, &) + within_list { within(item_selector, record.name, &) } + end + + def expect_order(*names) + within_list do + expect(page).to have_selector(item_selector, count: names.size) + names.each_with_index do |name, index| + expect(page).to have_selector(item_selector, name, position: index + 1) + end + end + end + + def expect_move_settled(*names) + expect_and_dismiss_flash(message: I18n.t(:enumeration_caption_order_changed)) + expect_order(*names) + expect(page).to have_no_css("[data-sortable-lists-busy]") + end + + def within_menu(record, &) + within_row(record) do + button = find(:button, accessible_name: actions_label) + within(open_controlled_menu(button), &) + end + end + + def within_move_submenu(record, &) + within_menu(record) do |menu| + within(open_controlled_menu(menu.find(:menuitem, I18n.t(:button_move))), &) + end + end + + def move(record, direction_label) + within_move_submenu(record) do |submenu| + submenu.find(:menuitem, direction_label).click + end + end + + def drag(record, after:) + handle = drag_handle(record) + target = row(after) + offset_y = (target.native.rect.height / 2) - [6, target.native.rect.height / 4].min + + perform_native_drag(source: handle, target:, offset_y: offset_y.round) + + # Assert Pragmatic DnD tore down its own honey-pot overlay, so a regression + # leaving it stuck is caught here rather than as an unrelated click failure. + expect(page).to have_no_css("[data-pdnd-honey-pot]", wait: 2, visible: :all) + end + + private + + def row(record) + within_list { find(item_selector, record.name) } + end + + def drag_handle(record) + row(record).find(:button, accessible_name: I18n.t("drag_handle.button_drag")) + end + + def open_controlled_menu(button) + button.click + page.find(:menu, id: button["aria-controls"]) + end + end + end +end diff --git a/spec/support/pages/admin/work_package_priorities.rb b/spec/support/pages/admin/work_package_priorities.rb new file mode 100644 index 000000000000..f4d1dff4c43c --- /dev/null +++ b/spec/support/pages/admin/work_package_priorities.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "support/pages/admin/enumeration_list" + +module Pages + module Admin + class WorkPackagePriorities < EnumerationList + def path = admin_settings_work_package_priorities_path + + def list_selector = "#admin-enumerations-index-component" + + def actions_label = "Actions" + end + end +end diff --git a/spec/support/shared/enumeration_admin_helpers.rb b/spec/support/shared/enumeration_admin_helpers.rb deleted file mode 100644 index 599e847f57cd..000000000000 --- a/spec/support/shared/enumeration_admin_helpers.rb +++ /dev/null @@ -1,95 +0,0 @@ -# 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. -#++ - -# Menu and ordering helpers for the enumeration admin lists (priorities, -# time entry activities, document types). Included per feature spec. -# Consumers define `enumeration_list_selector` and `enumeration_actions_label`; -# tables override `enumeration_item_selector` with `:row`. -module EnumerationAdminHelpers - def enumeration_item_selector = :list_item - - def within_enumeration_list(&) - page.within(enumeration_list_selector, &) - end - - def expect_enumeration_order(*names) - within_enumeration_list do - expect(page).to have_selector(enumeration_item_selector, count: names.size) - names.each_with_index do |name, index| - expect(page).to have_selector(enumeration_item_selector, name, position: index + 1) - end - end - end - - def expect_enumeration_move_settled(*names) - expect_and_dismiss_flash(message: I18n.t(:enumeration_caption_order_changed)) - expect_enumeration_order(*names) - expect(page).to have_no_css("[data-sortable-lists-busy]") - end - - def within_enumeration_menu(record, &) - within_enumeration_list do - within(enumeration_item_selector, record.name) do - button = find(:button, accessible_name: enumeration_actions_label) - within(open_controlled_menu(button), &) - end - end - end - - def within_enumeration_move_submenu(record, &) - within_enumeration_menu(record) do |menu| - within(open_controlled_menu(menu.find(:menuitem, I18n.t(:button_move))), &) - end - end - - def move_enumeration(record, direction_label) - within_enumeration_move_submenu(record) do |submenu| - submenu.find(:menuitem, direction_label).click - end - end - - def enumeration_drag_handle(record) - within_enumeration_list do - find(enumeration_item_selector, record.name) - .find(:button, accessible_name: I18n.t("drag_handle.button_drag")) - end - end - - def enumeration_row(record) - within_enumeration_list { find(enumeration_item_selector, record.name) } - end - - private - - def open_controlled_menu(button) - button.click - page.find(:menu, id: button["aria-controls"]) - end -end From b876c287b5f1d7ffcb9372f121d2d8f59cf7302b Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 13:59:16 +0100 Subject: [PATCH 4/8] [DREAM-840] Restore documents count selector Brings back the documents-count test selector the old template had, so the feature spec no longer finds the count through its column index and keeps working when columns are added or reordered. The row component spec locates the cell by its column class for the same reason. https://community.openproject.org/wp/DREAM-840 --- .../components/documents/admin/document_types/row_component.rb | 2 +- .../documents/admin/document_types/row_component_spec.rb | 3 ++- .../features/documents/admin/settings/document_types_spec.rb | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/modules/documents/app/components/documents/admin/document_types/row_component.rb b/modules/documents/app/components/documents/admin/document_types/row_component.rb index bf2376aea4f9..5259707779d0 100644 --- a/modules/documents/app/components/documents/admin/document_types/row_component.rb +++ b/modules/documents/app/components/documents/admin/document_types/row_component.rb @@ -59,7 +59,7 @@ def name end def documents_count - render(Primer::Beta::Text.new(color: :subtle)) do + render(Primer::Beta::Text.new(color: :subtle, test_selector: "documents-count")) do document_type.documents_count.to_s end end diff --git a/modules/documents/spec/components/documents/admin/document_types/row_component_spec.rb b/modules/documents/spec/components/documents/admin/document_types/row_component_spec.rb index 4cc4dd566629..617dfa691784 100644 --- a/modules/documents/spec/components/documents/admin/document_types/row_component_spec.rb +++ b/modules/documents/spec/components/documents/admin/document_types/row_component_spec.rb @@ -48,7 +48,8 @@ it "shows how many documents use the type in the Documents column" do create_list(:document, 2, type: document_type) - expect(rendered_component).to have_css("[role='cell'][aria-colindex='2']", exact_text: "2", normalize_ws: true) + expect(rendered_component) + .to have_css(".op-border-box-grid__row-item.documents_count", exact_text: "2", normalize_ws: true) end it "targets the drag handle for the item controller" do diff --git a/modules/documents/spec/features/documents/admin/settings/document_types_spec.rb b/modules/documents/spec/features/documents/admin/settings/document_types_spec.rb index 42d0ab557692..164252b323c4 100644 --- a/modules/documents/spec/features/documents/admin/settings/document_types_spec.rb +++ b/modules/documents/spec/features/documents/admin/settings/document_types_spec.rb @@ -146,7 +146,7 @@ expect(document.reload.type).to eq another_type list_page.within_row(another_type) do - expect(page).to have_css("[role='cell'][aria-colindex='2']", exact_text: "1", normalize_ws: true) + expect(page).to have_test_selector("documents-count", exact_text: "1") end # It allows deleting unused document types From 0094fc168405d0868adf3b6555ae4dceac1298e7 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 14:00:26 +0100 Subject: [PATCH 5/8] [DREAM-840] Align table with sibling tables Gives the blank state the alert icon the statuses table and the enumeration lists show, and drops the comment on the rows container selector, which the statuses and roles tables set without one. https://community.openproject.org/wp/DREAM-840 --- .../documents/admin/document_types/table_component.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/documents/app/components/documents/admin/document_types/table_component.rb b/modules/documents/app/components/documents/admin/document_types/table_component.rb index 046ea5aa4dd0..b81fdd010aaf 100644 --- a/modules/documents/app/components/documents/admin/document_types/table_component.rb +++ b/modules/documents/app/components/documents/admin/document_types/table_component.rb @@ -51,6 +51,8 @@ def headers ] end + def blank_icon = :alert + def blank_title = I18n.t(:no_results_title_text) def blank_description = nil @@ -61,7 +63,6 @@ def container_data sortable_lists__list_type_value: DocumentType.model_name.param_key, sortable_lists__list_accepted_type_value: DocumentType.model_name.param_key, sortable_lists__list_name_value: DocumentType.model_name.human(count: :other), - # The rows sit in a div, not in the `ul` the list controller looks for by default. sortable_lists__list_rows_container_element: ":scope > .#{rows_container_class}" } end From 8de58df8dbf9a12a6bb7022ddc5c6e9a0130b084 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 14:13:27 +0100 Subject: [PATCH 6/8] Share the Move submenu across sortable menus Adds with_move_submenu to SortableLists::MoveMenu beside with_move_items, so the submenu's label, icon, select variant and moveMenu target are stated once. Document types, enumerations and roles call it in place of their own copies; the rendered markup is unchanged. Menus that render the directions flat, such as text transform actions, keep calling with_move_items. --- .../admin/enumerations/item_component.rb | 16 +--- app/components/roles/row_component.rb | 16 +--- app/components/sortable_lists/move_menu.rb | 14 ++++ .../admin/document_types/row_component.rb | 16 +--- .../sortable_lists/move_menu_spec.rb | 84 +++++++++++++------ 5 files changed, 75 insertions(+), 71 deletions(-) diff --git a/app/components/admin/enumerations/item_component.rb b/app/components/admin/enumerations/item_component.rb index 2f44dcbd622e..e9cc0397d51c 100644 --- a/app/components/admin/enumerations/item_component.rb +++ b/app/components/admin/enumerations/item_component.rb @@ -54,7 +54,7 @@ def wrapper_uniq_by def build_enumeration_menu(menu) with_item_group(menu) do edit_enumeration(menu) - move_enumeration(menu) + with_move_submenu(menu) end with_item_group(menu) { deletion_enumeration(menu) } end @@ -67,20 +67,6 @@ def edit_enumeration(menu) end end - def move_enumeration(menu) - menu.with_item( - component_klass: Primer::Alpha::ActionMenu::SubMenuItem, - label: I18n.t(:button_move), - select_variant: :none, - form_arguments: {}, - data: { sortable_lists__item_target: "moveMenu" } - ) do |submenu| - submenu.with_leading_visual_icon(icon: :"op-arrow-in") - - with_move_items(submenu) - end - end - def deletion_enumeration(menu) menu.with_item(label: I18n.t(:button_delete), tag: :button, diff --git a/app/components/roles/row_component.rb b/app/components/roles/row_component.rb index 980e5ba29d2c..712281e6264a 100644 --- a/app/components/roles/row_component.rb +++ b/app/components/roles/row_component.rb @@ -111,7 +111,7 @@ def action_menu ) edit_action(menu) - move_action(menu) if movable? + with_move_submenu(menu) if movable? if deletable? menu.with_divider @@ -126,20 +126,6 @@ def edit_action(menu) end end - def move_action(menu) - menu.with_item( - component_klass: Primer::Alpha::ActionMenu::SubMenuItem, - label: t(:button_move), - select_variant: :none, - form_arguments: {}, - data: { sortable_lists__item_target: "moveMenu" } - ) do |submenu| - submenu.with_leading_visual_icon(icon: :"op-arrow-in") - - with_move_items(submenu) - end - end - def delete_action(menu) menu.with_item( label: t(:button_delete), diff --git a/app/components/sortable_lists/move_menu.rb b/app/components/sortable_lists/move_menu.rb index d569f6c3b4f0..53fbb0e1ce97 100644 --- a/app/components/sortable_lists/move_menu.rb +++ b/app/components/sortable_lists/move_menu.rb @@ -49,6 +49,20 @@ def item_data private + def with_move_submenu(menu) + menu.with_item( + component_klass: Primer::Alpha::ActionMenu::SubMenuItem, + label: I18n.t(:button_move), + select_variant: :none, + form_arguments: {}, + data: { sortable_lists__item_target: "moveMenu" } + ) do |submenu| + submenu.with_leading_visual_icon(icon: :"op-arrow-in") + + with_move_items(submenu) + end + end + # The `data:` hash must live on the item level so Primer renders it on the ActionList # `
  • `, which is what the item controller targets to compute availability and to # handle the bubbled click. diff --git a/modules/documents/app/components/documents/admin/document_types/row_component.rb b/modules/documents/app/components/documents/admin/document_types/row_component.rb index 5259707779d0..fe22391164a9 100644 --- a/modules/documents/app/components/documents/admin/document_types/row_component.rb +++ b/modules/documents/app/components/documents/admin/document_types/row_component.rb @@ -119,7 +119,7 @@ def action_menu def build_document_type_menu(menu) with_item_group(menu) do edit_document_type(menu) - move_document_type(menu) + with_move_submenu(menu) end with_item_group(menu) { delete_document_type(menu) } end @@ -134,20 +134,6 @@ def edit_document_type(menu) end end - def move_document_type(menu) - menu.with_item( - component_klass: Primer::Alpha::ActionMenu::SubMenuItem, - label: I18n.t(:button_move), - select_variant: :none, - form_arguments: {}, - data: { sortable_lists__item_target: "moveMenu" } - ) do |submenu| - submenu.with_leading_visual_icon(icon: :"op-arrow-in") - - with_move_items(submenu) - end - end - def delete_document_type(menu) menu.with_item( label: I18n.t(:button_delete), diff --git a/spec/components/sortable_lists/move_menu_spec.rb b/spec/components/sortable_lists/move_menu_spec.rb index be3b648457b0..32484dbc62d4 100644 --- a/spec/components/sortable_lists/move_menu_spec.rb +++ b/spec/components/sortable_lists/move_menu_spec.rb @@ -31,7 +31,7 @@ require "rails_helper" RSpec.describe SortableLists::MoveMenu, type: :component do - let(:harness_class) do + def harness_class_calling(builder) Class.new(ApplicationComponent) do include SortableLists::MoveMenu @@ -39,50 +39,82 @@ def self.name "MoveMenuHarnessComponent" end - def call + define_method(:call) do render(Primer::Alpha::ActionMenu.new) do |menu| menu.with_show_button { "Actions" } - with_move_items(menu) + send(builder, menu) end end end end - let(:move_items) do - page.all("li[data-sortable-lists--item-target~='moveItem']", visible: :all) + let(:directions) do + { + "top" => ["Move to top", :"move-to-top"], + "up" => ["Move up", :"chevron-up"], + "down" => ["Move down", :"chevron-down"], + "bottom" => ["Move to bottom", :"move-to-bottom"] + } end before { render_inline(harness_class.new) } - it "renders the four directions in top, up, down, bottom order" do - expect(move_items.pluck("data-sortable-lists--item-direction-param")) - .to eq(%w[top up down bottom]) - end + describe "#with_move_items" do + let(:harness_class) { harness_class_calling(:with_move_items) } - it "wires every item to the item controller's move action" do - expect(move_items.pluck("data-action")) - .to all(eq("click->sortable-lists--item#move")) - end + it "renders the four directions in top, up, down, bottom order" do + expect(page.all(:menuitem).map { it.text.squish }) + .to eq(["Move to top", "Move up", "Move down", "Move to bottom"]) + end - it "labels each direction with the existing sort translations and icons" do - { - "top" => [:label_sort_highest, "move-to-top"], - "up" => [:label_sort_higher, "chevron-up"], - "down" => [:label_sort_lower, "chevron-down"], - "bottom" => [:label_sort_lowest, "move-to-bottom"] - }.each do |direction, (label, icon)| - item = page.find("li[data-sortable-lists--item-direction-param='#{direction}']", visible: :all) - - expect(item).to have_button(I18n.t(label), visible: :all) - expect(item).to have_css(".octicon-#{icon}", visible: :all) + it "gives each direction its icon", :aggregate_failures do + directions.each_value do |label, icon| + expect(page).to have_selector(:menuitem, label) do |item| + expect(item).to have_octicon(icon) + end + end + end + + it "wires every item to the item controller's move action", :aggregate_failures do + directions.each do |direction, (label, _icon)| + expect(page).to have_element(:li, "data-sortable-lists--item-direction-param": direction) do |item| + expect(item["data-sortable-lists--item-target"]).to eq("moveItem") + expect(item["data-action"]).to eq("click->sortable-lists--item#move") + expect(item).to have_selector(:menuitem, label) + end + end end end - it "does not expose the builder as public component API" do - expect(harness_class.new).not_to respond_to(:with_move_items) + describe "#with_move_submenu" do + let(:harness_class) { harness_class_calling(:with_move_submenu) } + + it "renders a Move item with the incoming-arrow icon that opens a submenu", :aggregate_failures do + expect(page).to have_selector(:menuitem, "Move", exact: true, count: 1) do |item| + expect(item).to have_octicon(:"op-arrow-in") + expect(item["aria-haspopup"]).to eq("true") + end + end + + it "marks the Move item as the target the item controller hides" do + expect(page).to have_element(:li, "data-sortable-lists--item-target": "moveMenu", count: 1) do |item| + expect(item).to have_selector(:menuitem, "Move", exact: true) + end + end + + it "renders the four directions into the submenu the Move item controls" do + move_item = page.find(:menuitem, "Move", exact: true) + + expect(page).to have_selector(:menu, id: move_item["aria-controls"]) do |submenu| + expect(submenu.all(:menuitem).map { it.text.squish }) + .to eq(["Move to top", "Move up", "Move down", "Move to bottom"]) + end + end end describe "DIRECTIONS" do + let(:harness_class) { harness_class_calling(:with_move_items) } + it "is frozen" do expect(described_class::DIRECTIONS).to be_frozen end From 01aa68d736c3f920c78a3389b39b623f217126f1 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 14:15:46 +0100 Subject: [PATCH 7/8] [DREAM-840] Trim redundant component specs Drops five examples that repeat what the shared sortable-lists examples assert or that only prove deleted markup stays deleted. The per-row example stays, reduced to the row id and the preview target, which the shared examples do not cover. Renames the action button example, whose label is the same on every row, and shortens the page object comment to its purpose. https://community.openproject.org/wp/DREAM-840 --- .../document_types/index_component_spec.rb | 21 ------------------- .../document_types/row_component_spec.rb | 2 +- .../document_types/table_component_spec.rb | 17 +-------------- spec/support/pages/admin/enumeration_list.rb | 4 +--- 4 files changed, 3 insertions(+), 41 deletions(-) diff --git a/modules/documents/spec/components/documents/admin/document_types/index_component_spec.rb b/modules/documents/spec/components/documents/admin/document_types/index_component_spec.rb index e5c622ce3b79..c4f3a2d875d5 100644 --- a/modules/documents/spec/components/documents/admin/document_types/index_component_spec.rb +++ b/modules/documents/spec/components/documents/admin/document_types/index_component_spec.rb @@ -50,27 +50,11 @@ move_url_template: "/admin/settings/document_types/{id}/move" it_behaves_like "no legacy drag-and-drop wiring" - it "resolves both outlets inside the wrapper", :aggregate_failures do - expect(rendered_component) - .to have_css("#documents-admin-document-types-index-component [data-controller~='sortable-lists--list']", - count: 1) - expect(rendered_component) - .to have_css("#documents-admin-document-types-index-component [data-controller~='sortable-lists--item']", - count: 2) - end - it_behaves_like "a sortable-lists list", list_type: "document_type", name: "Document types", rows_container: ":scope > .op-border-box-table--rows" - it "leaves the list wiring to the table container" do - expect(rendered_component).to have_css("#documents-admin-document-types-index-component") do |wrapper| - expect(wrapper["data-sortable-lists--list-type-value"]).to be_nil - expect(wrapper["data-sortable-lists--list-rows-container-element"]).to be_nil - end - end - it "offers adding a document type above the list" do expect(rendered_component).to have_test_selector("add-document-type-button") end @@ -81,9 +65,4 @@ expect(table).to have_selector(:row, "Report") end end - - it "drops the bespoke grid and its markup", :aggregate_failures do - expect(rendered_component).to have_no_css(".op-documents-types-list--header") - expect(rendered_component).to have_no_css(".op-documents-types-list--item") - end end diff --git a/modules/documents/spec/components/documents/admin/document_types/row_component_spec.rb b/modules/documents/spec/components/documents/admin/document_types/row_component_spec.rb index 617dfa691784..d6d0efb3e4dd 100644 --- a/modules/documents/spec/components/documents/admin/document_types/row_component_spec.rb +++ b/modules/documents/spec/components/documents/admin/document_types/row_component_spec.rb @@ -87,7 +87,7 @@ expect(rendered_component).to have_css(".DragHandle.hide-when-print", visible: :all) end - it "sits behind a button named for the document type" do + it "sits behind a labelled actions button" do expect(rendered_component).to have_button(accessible_name: I18n.t("documents.document_type_actions")) end diff --git a/modules/documents/spec/components/documents/admin/document_types/table_component_spec.rb b/modules/documents/spec/components/documents/admin/document_types/table_component_spec.rb index 9256eba49bd4..dd1823f6760d 100644 --- a/modules/documents/spec/components/documents/admin/document_types/table_component_spec.rb +++ b/modules/documents/spec/components/documents/admin/document_types/table_component_spec.rb @@ -76,30 +76,15 @@ let(:sortable_records) { [note, report] } end - it "leaves the sortable root, its outlets and the move URL to the index wrapper" do - expect(rendered_component).to have_css("[data-controller~='sortable-lists--list']") do |container| - expect(container["data-sortable-lists-move-url-template-value"]).to be_nil - expect(container["data-sortable-lists-sortable-lists--list-outlet"]).to be_nil - expect(container["data-sortable-lists-sortable-lists--item-outlet"]).to be_nil - end - expect(rendered_component).to have_no_css("[data-controller~='sortable-lists']") - end - - it "identifies each row and drags it whole" do + it "identifies each row and drags it whole", :aggregate_failures do [note, report].each do |document_type| expect(rendered_component) .to have_css(".Box-row[data-sortable-lists--item-id-value='#{document_type.id}']") do |row| expect(row["id"]).to eq("document-type-#{document_type.id}") expect(row["data-sortable-lists--item-target"]).to eq("preview") - expect(row["data-test-selector"]).to eq("document-type-row-#{document_type.id}") end end end - - it "drops the bespoke grid the table replaces", :aggregate_failures do - expect(rendered_component).to have_no_css(".op-documents-types-list--header") - expect(rendered_component).to have_no_css(".op-documents-types-list--item") - end end context "without document types" do diff --git a/spec/support/pages/admin/enumeration_list.rb b/spec/support/pages/admin/enumeration_list.rb index 6f6eded74096..6217560abad3 100644 --- a/spec/support/pages/admin/enumeration_list.rb +++ b/spec/support/pages/admin/enumeration_list.rb @@ -33,9 +33,7 @@ module Pages module Admin # Drives the enumeration admin lists (priorities, time entry activities, - # document types). Subclasses define `path`, `list_selector` and - # `actions_label`, and override `item_selector` for tables; records are - # addressed by name. + # document types); records are addressed by name. class EnumerationList < ::Pages::Page def item_selector = :list_item From 245904d9c962e9cfdc149769a2692cd6e796cb35 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 28 Sep 2026 14:24:54 +0100 Subject: [PATCH 8/8] [DREAM-840] Use accessible selectors in row spec Locates the drag handle, the labels and the menu items of the document type row by role and accessible name instead of CSS and test selectors, and resolves the Move submenu through the item that controls it. https://community.openproject.org/wp/DREAM-840 --- .../document_types/row_component_spec.rb | 44 +++++++++---------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/modules/documents/spec/components/documents/admin/document_types/row_component_spec.rb b/modules/documents/spec/components/documents/admin/document_types/row_component_spec.rb index d6d0efb3e4dd..f5a1f73f0d36 100644 --- a/modules/documents/spec/components/documents/admin/document_types/row_component_spec.rb +++ b/modules/documents/spec/components/documents/admin/document_types/row_component_spec.rb @@ -53,7 +53,9 @@ end it "targets the drag handle for the item controller" do - expect(rendered_component).to have_css(".DragHandle[data-sortable-lists--item-target~='handle']") + expect(rendered_component).to have_button(accessible_name: "Drag to reorder") do |handle| + expect(handle["data-sortable-lists--item-target"]).to eq("handle") + end end describe "labels beside the name" do @@ -61,7 +63,7 @@ let!(:document_type) { create(:document_type, name: "Note", is_default: true) } it "labels it as the default" do - expect(rendered_component).to have_test_selector("label-is-default", text: I18n.t(:label_default)) + expect(rendered_component).to have_primer_label("Default", scheme: :primary, count: 1) end end @@ -69,51 +71,47 @@ let!(:document_type) { create(:document_type, name: "Note", active: false) } it "labels it as inactive" do - expect(rendered_component).to have_test_selector("label-inactive", text: I18n.t(:label_inactive)) + expect(rendered_component).to have_primer_label("Inactive", count: 1) end end context "when the document type is active and not the default one" do - it "carries neither label", :aggregate_failures do - expect(rendered_component).to have_no_test_selector("label-is-default") - expect(rendered_component).to have_no_test_selector("label-inactive") + it "carries no label" do + expect(rendered_component).to have_no_primer_label end end end describe "the action menu" do it "keeps the actions out of print, like the drag handle", :aggregate_failures do - expect(rendered_component).to have_css("action-menu.hide-when-print", visible: :all) - expect(rendered_component).to have_css(".DragHandle.hide-when-print", visible: :all) + expect(rendered_component).to have_css("action-menu.hide-when-print") + expect(rendered_component).to have_css(".DragHandle.hide-when-print") end it "sits behind a labelled actions button" do - expect(rendered_component).to have_button(accessible_name: I18n.t("documents.document_type_actions")) + expect(rendered_component).to have_button(accessible_name: "Document type actions") end # The four directions themselves are covered by the SortableLists::MoveMenu spec. - it "renders the Move submenu carrying the moveMenu target with the shared move items" do - expect(rendered_component).to have_css("li[data-sortable-lists--item-target~='moveMenu']", visible: :all) do |item| - expect(item).to have_css("[role='menuitem']", text: I18n.t(:button_move), visible: :all) do |trigger| - expect(rendered_component) - .to have_css("##{trigger['aria-controls']} li[data-sortable-lists--item-target~='moveItem']", count: 4, visible: :all) + it "offers the shared Move submenu with its four directions" do + expect(rendered_component).to have_selector(:menuitem, "Move", exact: true, count: 1) do |move_item| + expect(rendered_component).to have_selector(:menu, id: move_item["aria-controls"]) do |submenu| + expect(submenu).to have_selector(:menuitem, count: 4) end end end it "renders exactly one divider, so hiding the move submenu leaves a single separator" do - expect(rendered_component).to have_css("li.ActionList-sectionDivider", count: 1, visible: :all) + expect(rendered_component).to have_css("li.ActionList-sectionDivider", count: 1) end it "keeps Edit and the async-dialog Delete in the menu, without a legacy move form", :aggregate_failures do - expect(rendered_component) - .to have_link(I18n.t(:button_edit), - href: "/admin/settings/document_types/#{document_type.id}/edit", - visible: :all) - expect(rendered_component) - .to have_link(I18n.t(:button_delete), - href: "/admin/settings/document_types/#{document_type.id}/delete_dialog", - visible: :all) + expect(rendered_component).to have_selector(:menuitem, "Edit", exact: true) do |item| + expect(item[:href]).to eq("/admin/settings/document_types/#{document_type.id}/edit") + end + expect(rendered_component).to have_selector(:menuitem, "Delete", exact: true) do |item| + expect(item[:href]).to eq("/admin/settings/document_types/#{document_type.id}/delete_dialog") + end expect(rendered_component).to have_no_field("move_to", type: :hidden) expect(rendered_component).to have_no_field("position", type: :hidden) end