diff --git a/CHANGELOG.md b/CHANGELOG.md index ccdcb76..f39d671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed + - `table_cell_selector`, `table_header_selector` and `attributes_row_selector` + now derive the CSS class with `parameterize(separator: '_')`, the same call + ActiveAdmin makes in `AttributesTable#row` and `TableFor::Column#html_class`, + instead of three different hand-rolled substitutions. Labels containing + punctuation change selector: `'E-mail'` was `col-email` and is now + `col-e-mail`, `"Customer's Name"` was `col-customers_name` and is now + `col-customer_s_name`, `:full_name` was `col-fullname` and is now + `col-full_name`. Previously these matched nothing (or raised + `Nokogiri::CSS::SyntaxError`), so an app carrying a workaround that + pre-normalized labels before calling these helpers should drop it. + - `table_header_selector(nil, column: 'x')` now honours the override instead + of returning the generic header selector. - tested against Ruby 3.3, 3.4, 4.0 and Rails 8.0, 8.1 (EOL Rails 7.2 dropped from CI) +### Fixed + - a label that `parameterize` cannot transliterate (`'Имя'`, `'名前'`, `'№'`, or + a blank string) now raises `ArgumentError` instead of building a bare + `.row-` / `.col-` selector. ActiveAdmin renders the same empty class for + every such label, so that selector matched all of them at once and the + matcher could pass against the wrong row. + ## [1.0.0] - 2026-04-09 ### Added diff --git a/lib/capybara/active_admin/selectors/attributes_table.rb b/lib/capybara/active_admin/selectors/attributes_table.rb index b00d134..c822f31 100644 --- a/lib/capybara/active_admin/selectors/attributes_table.rb +++ b/lib/capybara/active_admin/selectors/attributes_table.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'capybara/active_admin/util' + module Capybara module ActiveAdmin module Selectors diff --git a/lib/capybara/active_admin/selectors/table.rb b/lib/capybara/active_admin/selectors/table.rb index 67012e8..ec08fe3 100644 --- a/lib/capybara/active_admin/selectors/table.rb +++ b/lib/capybara/active_admin/selectors/table.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'capybara/active_admin/util' + module Capybara module ActiveAdmin module Selectors @@ -29,7 +31,7 @@ def table_row_selector(record_id = nil) # @option sort_direction [String, nil] sort direction ('asc' or 'desc'). # @return selector. def table_header_selector(text = nil, options = {}) - return 'thead > tr > th.col' if text.nil? + return 'thead > tr > th.col' if text.nil? && options[:column].nil? column = Util.css_class_for_label(options[:column] || text) selector = "th.col.col-#{column}" diff --git a/lib/capybara/active_admin/util.rb b/lib/capybara/active_admin/util.rb index 6ca6f2f..7dda57c 100644 --- a/lib/capybara/active_admin/util.rb +++ b/lib/capybara/active_admin/util.rb @@ -19,7 +19,20 @@ def parse_model_name(model_name, singular: true) # that call rather than approximating it, so every label lands on # the class ActiveAdmin actually rendered. def css_class_for_label(label) - label.to_s.parameterize(separator: '_') + css_class = label.to_s.parameterize(separator: '_') + return css_class if css_class.present? + + # `parameterize` transliterates to Latin and drops the rest, so any + # label without Latin-transliterable characters -- 'Имя', '名前', '№', + # or a blank string -- comes back empty. ActiveAdmin makes the same + # call, so it renders `class="row row-"` for all of them, and a + # `.row-` selector would match every one at once: the matcher would + # pass against the wrong row instead of failing. Refuse to build it. + raise ArgumentError, + "cannot derive a CSS class from #{label.inspect}: " \ + 'ActiveAdmin parameterizes it to an empty string, so every such ' \ + 'row or column shares one class and cannot be told apart. ' \ + 'Match on text, or pass the attribute name instead of the label.' end def options_with_text(text, options = {}) diff --git a/spec/lib/selectors/attributes_table_spec.rb b/spec/lib/selectors/attributes_table_spec.rb index 301f327..301ba53 100644 --- a/spec/lib/selectors/attributes_table_spec.rb +++ b/spec/lib/selectors/attributes_table_spec.rb @@ -6,6 +6,19 @@ end describe '#attributes_row_selector' do + # ActiveAdmin derives the class with the same `parameterize` call, so a + # label it cannot transliterate renders as a bare `row-` on every such + # row. Building that selector would match them all and pass against the + # wrong one, so it has to raise instead. + it 'refuses a label that parameterizes to nothing' do + expect { helper.attributes_row_selector('Имя') } + .to raise_error(ArgumentError, /cannot derive a CSS class from "Имя"/) + end + + it 'refuses a blank label' do + expect { helper.attributes_row_selector(' ') }.to raise_error(ArgumentError) + end + it 'returns a generic row selector when no label given' do expect(helper.attributes_row_selector).to eq('tr.row > td') end @@ -14,7 +27,7 @@ expect(helper.attributes_row_selector('Full name')).to eq('tr.row.row-full_name > td') end - it 'replaces slash with underscore to match ActiveAdmin class generation' do + it 'substitutes a slash with the separator' do expect(helper.attributes_row_selector('USA/UAH')).to eq('tr.row.row-usa_uah > td') end diff --git a/spec/lib/selectors/table_spec.rb b/spec/lib/selectors/table_spec.rb index 85007dc..8c0bb53 100644 --- a/spec/lib/selectors/table_spec.rb +++ b/spec/lib/selectors/table_spec.rb @@ -36,6 +36,11 @@ .to eq('thead > tr > th.col.col-vat_tax_number') end + it 'honours a column override even with no visible text' do + expect(helper.table_header_selector(nil, column: 'full_name')) + .to eq('thead > tr > th.col.col-full_name') + end + it 'appends the sortable and sorted classes' do expect(helper.table_header_selector('Full Name', sortable: true, sort_direction: 'DESC')) .to eq('thead > tr > th.col.col-full_name.sortable.sorted-desc') @@ -47,6 +52,12 @@ expect(helper.table_cell_selector).to eq('td.col') end + # `column :full_name` is the commonest input of all, and the derivation + # this replaced stripped the underscore out of it: col-fullname. + it 'keeps the underscore in a symbol attribute name' do + expect(helper.table_cell_selector(:full_name)).to eq('td.col.col-full_name') + end + it 'converts spaces to underscores' do expect(helper.table_cell_selector('Full Name')).to eq('td.col.col-full_name') end @@ -55,11 +66,11 @@ expect(helper.table_cell_selector('ID')).to eq('td.col.col-id') end - it 'strips slashes from column name' do + it 'substitutes a slash with the separator' do expect(helper.table_cell_selector('Country / Region')).to eq('td.col.col-country_region') end - it 'strips other special characters from column name' do + it 'substitutes bracket punctuation with the separator' do expect(helper.table_cell_selector('Price (USD)')).to eq('td.col.col-price_usd') end