mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
0b4bcb5d3c
capybara_accessible_selectors 0.16 resolves role selectors such as
`:columnheader` by the computed accessible name. In a browser that name
folds in the CSS `text-transform: uppercase` styling and the column
action-menu trigger text, so `have_columnheader("Subject")` no longer
matches a header whose accessible name resolves to "SUBJECT Open menu".
Sets an explicit `aria-label` equal to the plain caption on each header
cell, in both the server-rendered tables and the work package table, so
the accessible name is the column title alone. This also improves screen
reader output, which previously announced the uppercased text and the
menu label.
461 lines
17 KiB
Ruby
461 lines
17 KiB
Ruby
# 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 SortHelper do
|
|
describe "#sort_init/#sort_update/#sort_clause" do
|
|
# Needed to mimic being included in a controller
|
|
def controller_name; "foo"; end
|
|
|
|
def action_name; "bar"; end
|
|
|
|
before do
|
|
sort_init "attr1", "desc"
|
|
end
|
|
|
|
context "with arrays" do
|
|
before do
|
|
sort_update(%w[attr1 attr2])
|
|
end
|
|
|
|
it "returns the first attr in descending order" do
|
|
expect(sort_clause)
|
|
.to eql "attr1 DESC"
|
|
end
|
|
end
|
|
|
|
context "with hashes" do
|
|
before do
|
|
sort_update("attr1" => "table1.attr1", "attr2" => "table2.attr2")
|
|
end
|
|
|
|
it "returns the first attr in descending order with the table name prefixed" do
|
|
expect(sort_clause)
|
|
.to eql "table1.attr1 DESC"
|
|
end
|
|
end
|
|
|
|
context "with hashes sorting by multiple values" do
|
|
before do
|
|
sort_update("attr1" => %w[table1.attr1 table1.attr2], "attr2" => "table2.attr2")
|
|
end
|
|
|
|
it "returns the first attr in descending order" do
|
|
expect(sort_clause)
|
|
.to eql "table1.attr1 DESC, table1.attr2 DESC"
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "#sort_init/#sort_update/params/session" do
|
|
# Needed to mimic being included in a controller
|
|
def controller_name; "foo"; end
|
|
|
|
def action_name; "bar"; end
|
|
|
|
def params; { sort: sort_param }; end
|
|
|
|
def session; @session ||= {}; end
|
|
|
|
before do
|
|
sort_init "attr1", "desc"
|
|
sort_update("attr1" => %w[table1.attr1 table1.attr2], "attr2" => "table2.attr2")
|
|
end
|
|
|
|
context "with valid sort params" do
|
|
let(:sort_param) { "attr1,attr2:desc" }
|
|
|
|
it "persists the order in the session" do
|
|
expect(session["foo_bar_sort"])
|
|
.to eql "attr1,attr2:desc"
|
|
end
|
|
end
|
|
|
|
context "with invalid sort key" do
|
|
let(:sort_param) { "invalid_key" }
|
|
|
|
it "keeps the default sort in the session" do
|
|
expect(session["foo_bar_sort"])
|
|
.to eql "attr1:desc"
|
|
end
|
|
end
|
|
|
|
context "with invalid sort direction" do
|
|
let(:sort_param) { "attr1:blubs,attr2" }
|
|
|
|
it "falls back to the default sort order in the session" do
|
|
expect(session["foo_bar_sort"])
|
|
.to eql "attr1,attr2"
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "#sort_header_tag" do
|
|
subject(:output) do
|
|
helper.sort_header_tag("id", **options)
|
|
end
|
|
|
|
let(:options) { {} }
|
|
let(:sort_key) { "" }
|
|
let(:sort_asc) { true }
|
|
let(:sort_criteria) do
|
|
instance_double(SortHelper::SortCriteria,
|
|
first_key: sort_key,
|
|
first_asc?: sort_asc,
|
|
to_param: "sort_criteria_params").as_null_object
|
|
end
|
|
|
|
before do
|
|
# helper relies on this instance var
|
|
@sort_criteria = sort_criteria
|
|
|
|
# fake having called '/work_packages'
|
|
allow(helper)
|
|
.to receive(:url_options)
|
|
.and_return(url_options.merge(controller: "work_packages", action: "index"))
|
|
end
|
|
|
|
it "renders a th with a sort link" do
|
|
expect(output).to be_html_eql(<<-HTML)
|
|
<th title="Sort by "Id"" aria-label="Id">
|
|
<div class="generic-table--sort-header-outer">
|
|
<div class="generic-table--sort-header">
|
|
<span>
|
|
<a href="/work_packages?sort=sort_criteria_params"
|
|
rel="nofollow"
|
|
target="_top"
|
|
title="Sort by "Id"">Id</a>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</th>
|
|
HTML
|
|
end
|
|
|
|
context "when sorting by the column" do
|
|
let(:sort_key) { "id" }
|
|
|
|
it "adds the sort class" do
|
|
expect(output).to be_html_eql(<<-HTML)
|
|
<th title="Ascending sorted by "Id"" aria-label="Id">
|
|
<div class="generic-table--sort-header-outer">
|
|
<div class="generic-table--sort-header">
|
|
<span class="sort asc">
|
|
<a href="/work_packages?sort=sort_criteria_params"
|
|
rel="nofollow"
|
|
target="_top"
|
|
title="Ascending sorted by "Id"">Id</a>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</th>
|
|
HTML
|
|
end
|
|
end
|
|
|
|
context "when sorting desc by the column" do
|
|
let(:sort_key) { "id" }
|
|
let(:sort_asc) { false }
|
|
|
|
it "adds the sort class" do
|
|
expect(output).to be_html_eql(<<-HTML)
|
|
<th title="Descending sorted by "Id"" aria-label="Id">
|
|
<div class="generic-table--sort-header-outer">
|
|
<div class="generic-table--sort-header">
|
|
<span class="sort desc">
|
|
<a href="/work_packages?sort=sort_criteria_params"
|
|
rel="nofollow"
|
|
target="_top"
|
|
title="Descending sorted by "Id"">Id</a>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</th>
|
|
HTML
|
|
end
|
|
end
|
|
|
|
describe "copying parameters" do
|
|
before do
|
|
controller.params = ActionController::Parameters.new(
|
|
filters: "xyz",
|
|
per_page: "42",
|
|
expand: "nope",
|
|
columns: "a,b,c",
|
|
foo: "bar",
|
|
bar: "baz",
|
|
baz: "foo"
|
|
)
|
|
end
|
|
|
|
context "when not given allowed parameters" do
|
|
it "copies default ones to the link" do
|
|
expect(output).to be_html_eql(<<-HTML)
|
|
<th title="Sort by "Id"" aria-label="Id">
|
|
<div class="generic-table--sort-header-outer">
|
|
<div class="generic-table--sort-header">
|
|
<span>
|
|
<a href="/work_packages?columns=a%2Cb%2Cc&expand=nope&filters=xyz&per_page=42&sort=sort_criteria_params"
|
|
rel="nofollow"
|
|
target="_top"
|
|
title="Sort by "Id"">Id</a>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</th>
|
|
HTML
|
|
end
|
|
end
|
|
|
|
context "when given allowed parameters" do
|
|
let(:options) { { allowed_params: %w[foo baz lol] } }
|
|
|
|
it "copies them to the link" do
|
|
expect(output).to be_html_eql(<<-HTML)
|
|
<th title="Sort by "Id"" aria-label="Id">
|
|
<div class="generic-table--sort-header-outer">
|
|
<div class="generic-table--sort-header">
|
|
<span>
|
|
<a href="/work_packages?baz=foo&foo=bar&sort=sort_criteria_params"
|
|
rel="nofollow"
|
|
target="_top"
|
|
title="Sort by "Id"">Id</a>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</th>
|
|
HTML
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "passing data params" do
|
|
let(:options) { { data: { "turbo-stream": true } } }
|
|
|
|
it "includes the passed data param in the link" do
|
|
expect(output).to be_html_eql(<<~HTML)
|
|
<th title="Sort by "Id"" aria-label="Id">
|
|
<div class="generic-table--sort-header-outer">
|
|
<div class="generic-table--sort-header">
|
|
<span>
|
|
<a title="Sort by "Id""
|
|
data-turbo-stream="true"
|
|
rel="nofollow"
|
|
target="_top"
|
|
href="/work_packages?sort=sort_criteria_params">
|
|
Id
|
|
</a>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</th>
|
|
HTML
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "#sort_header_with_action_menu" do
|
|
let(:id_column) { Queries::Projects::Selects::Default.new "id" }
|
|
let(:options) { { param: :json, sortable: true } }
|
|
let(:sort_criteria) { SortHelper::SortCriteria.new }
|
|
|
|
let(:action_menu) do
|
|
# The resulting HTML is too big to assert in detail. We will only check some key parts to ensure it is
|
|
# an action menu with the expected content.
|
|
Nokogiri::HTML(output).at_css("th .generic-table--sort-header action-menu")
|
|
end
|
|
|
|
subject(:output) do
|
|
helper.sort_header_with_action_menu(id_column,
|
|
%w[name id description], {}, **options)
|
|
end
|
|
|
|
before do
|
|
# helper relies on this instance var
|
|
@sort_criteria = sort_criteria
|
|
|
|
# fake having called '/projects'
|
|
allow(helper)
|
|
.to receive(:url_options)
|
|
.and_return(url_options.merge(controller: "projects", action: "index"))
|
|
end
|
|
|
|
it "renders an action-menu button as column header" do
|
|
expect(action_menu.at_css("button#menu-id-button .Button-content .Button-label").text).to eq("Id")
|
|
end
|
|
|
|
it "does not render an icon by default" do
|
|
expect(action_menu.at_css(".generic-table--action-menu-button .Button-leadingVisual")).to be_blank
|
|
end
|
|
|
|
it "shows sorting actions in the action-menu" do
|
|
sort_desc = action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-sort-desc']")
|
|
expect(sort_desc.at_css(".ActionListItem-label").text.strip).to eq("Sort descending")
|
|
expect(sort_desc["href"]).to eq("/projects?sortBy=%5B%5B%22id%22%2C%22desc%22%5D%5D")
|
|
|
|
sort_asc = action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-sort-asc']")
|
|
expect(sort_asc.at_css(".ActionListItem-label").text.strip).to eq("Sort ascending")
|
|
expect(sort_asc["href"]).to eq("/projects?sortBy=%5B%5B%22id%22%2C%22asc%22%5D%5D")
|
|
end
|
|
|
|
context "with a column that is not sortable" do
|
|
let(:options) { { param: :json, sortable: false } }
|
|
|
|
it "does not show the sorting actions in the action-menu" do
|
|
expect(action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-sort-desc']")).to be_nil
|
|
|
|
expect(action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-sort-asc']")).to be_nil
|
|
end
|
|
end
|
|
|
|
it "shows an action to move columns left and right" do
|
|
move_left = action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-move-col-left']")
|
|
expect(move_left.at_css(".ActionListItem-label").text.strip).to eq("Move column left")
|
|
# The id column moved one place to the left and is now the first column instead of the second.
|
|
expect(move_left["href"]).to eq("/projects?columns=id+name+description")
|
|
|
|
move_right = action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-move-col-right']")
|
|
expect(move_right.at_css(".ActionListItem-label").text.strip).to eq("Move column right")
|
|
# The id column moved one place to the right and is now the last one.
|
|
expect(move_right["href"]).to eq("/projects?columns=name+description+id")
|
|
end
|
|
|
|
context "with the current column being the leftmost one" do
|
|
subject(:output) do
|
|
helper.sort_header_with_action_menu(id_column,
|
|
%w[id name description], {}, **options)
|
|
end
|
|
|
|
it "does not offer a 'move left' option" do
|
|
move_left = action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-move-col-left']")
|
|
expect(move_left).to be_nil
|
|
|
|
# But it offers a 'move right' option
|
|
move_right = action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-move-col-right']")
|
|
expect(move_right).not_to be_nil
|
|
end
|
|
end
|
|
|
|
context "with the current column being the rightmost one" do
|
|
subject(:output) do
|
|
helper.sort_header_with_action_menu(id_column,
|
|
%w[name description id], {}, **options)
|
|
end
|
|
|
|
it "does not offer a 'move right' option" do
|
|
move_right = action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-move-col-right']")
|
|
expect(move_right).to be_nil
|
|
|
|
# But it offers a 'move left' option
|
|
move_left = action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-move-col-left']")
|
|
expect(move_left).not_to be_nil
|
|
end
|
|
end
|
|
|
|
it "shows an action to add columns" do
|
|
add_col = action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-add-column']")
|
|
expect(add_col.at_css(".ActionListItem-label").text.strip).to eq("Add column")
|
|
# Check that the 'ConfigureViewModal' is opened on link click. This is where adding columns happens.
|
|
expect(add_col["href"]).to eq("/project_queries/configure_view_modal")
|
|
end
|
|
|
|
it "shows an action to remove a column" do
|
|
remove_col = action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-remove-column']")
|
|
expect(remove_col.at_css(".ActionListItem-label").text.strip).to eq("Remove column")
|
|
# The current column is removed from the columns-query:
|
|
expect(remove_col["href"]).to eq("/projects?columns=name+description")
|
|
end
|
|
|
|
it "shows a 'filter by' action" do
|
|
filter_by = action_menu.at_css("action-list .ActionListItem button[data-test-selector='id-filter-by']")
|
|
expect(filter_by.at_css(".ActionListItem-label").text.strip).to eq("Filter by")
|
|
# Check that the correct Stimulus controller with the correct data is referenced:
|
|
expect(filter_by["data-action"]).to eq("table-action-menu#filterBy")
|
|
expect(filter_by["data-filter-name"]).to eq("id")
|
|
end
|
|
|
|
context "with a filter mapping for the column" do
|
|
subject(:output) do
|
|
helper.sort_header_with_action_menu(id_column,
|
|
%w[name id description], { "id" => "id_code" }, **options)
|
|
end
|
|
|
|
it "shows a 'filter by' action with the mapped filter" do
|
|
filter_by = action_menu.at_css("action-list .ActionListItem button[data-test-selector='id-filter-by']")
|
|
expect(filter_by.at_css(".ActionListItem-label").text.strip).to eq("Filter by")
|
|
expect(filter_by["data-action"]).to eq("table-action-menu#filterBy")
|
|
# With a column mapping, the filter name is changed accordingly:
|
|
expect(filter_by["data-filter-name"]).to eq("id_code")
|
|
end
|
|
end
|
|
|
|
context "with the filter mapping specifying there is no filter for the column" do
|
|
subject(:output) do
|
|
# With the filter name mapped to nil, we expect no filter action to be present.
|
|
helper.sort_header_with_action_menu(id_column,
|
|
%w[name id description], { "id" => nil }, **options)
|
|
end
|
|
|
|
it "does not show a 'filter by' action" do
|
|
filter_by = action_menu.at_css("action-list .ActionListItem button[data-test-selector='id-filter-by']")
|
|
expect(filter_by).to be_nil
|
|
end
|
|
end
|
|
|
|
context "with a project phase column" do
|
|
let(:project_phase_definition) { create(:project_phase_definition) }
|
|
let(:life_cycle_column) { Queries::Projects::Selects::ProjectPhase.new("project_phase_#{project_phase_definition.id}") }
|
|
|
|
let(:options) { { caption: project_phase_definition.name } }
|
|
|
|
subject(:output) do
|
|
# Not setting any filter column mappings here, so for other column types, this should use the default filter
|
|
helper.sort_header_with_action_menu(life_cycle_column,
|
|
%W[name project_phase_#{project_phase_definition.id}], {}, **options)
|
|
end
|
|
|
|
it "never offers a filter by action" do
|
|
# But a life cycle column never offers a filter (until #59183 is implemented)
|
|
filter_by = action_menu.at_css("action-list .ActionListItem button[data-test-selector='id-filter-by']")
|
|
expect(filter_by).to be_nil
|
|
end
|
|
|
|
it "shows a commit icon in the header for gates" do
|
|
icon = action_menu.at_css(".generic-table--action-menu-button .Button-leadingVisual .octicon-op-phase")
|
|
expect(icon).to be_present
|
|
|
|
header_text = action_menu.at_css(".generic-table--action-menu-button .Button-label").text.strip
|
|
expect(header_text).to eq(life_cycle_column.caption)
|
|
end
|
|
end
|
|
end
|
|
end
|