Skip to content

Refuse to build a selector from a label that parameterizes to nothing - #55

Merged
Fivell merged 1 commit into
masterfrom
fix/blank-css-class-guard
Oct 1, 2026
Merged

Fivell merged 1 commit into
masterfrom
fix/blank-css-class-guard

Conversation

@Fivell

@Fivell Fivell commented Oct 1, 2026

Copy link
Copy Markdown
Member

Follow-up to #53, from a max-effort review of it. Six findings, one of them a regression #53 introduced.

The regression: a silent wrong match replaced a loud failure

parameterize transliterates to Latin and drops what it cannot, so a label with no Latin-transliterable characters comes back empty:

'Имя'.parameterize(separator: '_')      # => ""
'Телефон'.parameterize(separator: '_')  # => ""
'名前' / '№' / '' / '   '                # => ""

ActiveAdmin makes the same call, so it renders class="row row-" for every one of them. After #53 the gem built a selector to match that:

attributes_row_selector('Имя')      => tr.row.row- > td
attributes_row_selector('Телефон')  => tr.row.row- > td

One selector, every such row. On a page with both rows have_attribute_row('Имя', exact_text: '+380501112233') passes — asserting the name row holds a phone number — and within_attribute_row('Имя') raises Capybara::Ambiguous. table_cell_selector and find_table_cell have the same shape.

The derivation #53 replaced produced row-имя, matched nothing, and failed honestly. Being more faithful to ActiveAdmin is worth very little if the price is a false pass in a testing gem, so css_class_for_label now raises instead, naming the label and pointing at the alternative:

ArgumentError: cannot derive a CSS class from "Имя": 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.

This also covers the blank-label case, where the mismatch is sharper still: ActiveAdmin's guard is elsif title.present?, so row('') emits class="row" with no row- at all, and the old selector could never have matched.

Five more from the same review

Util was referenced without being required. selectors/table.rb and selectors/attributes_table.rb use Util.css_class_for_label but neither requires it. They load today only because capybara/active_admin.rb requires util one line before selectors; require 'capybara/active_admin/selectors' on its own raised NameError: uninitialized constant …Selectors::Table::Util.

table_header_selector(nil, column: 'x') discarded the override. The early return guarded on text.nil? while the line below normalized options[:column] || text, so the override never ran and find_table_header matched the first header on the page whatever column was asked for. Guard is now text.nil? && options[:column].nil?.

table_cell_selector(:full_name) had no example — the commonest input of all, and precisely what the pre-#53 derivation broke (col-fullname). Without a spec, a future "simplify this" refactor that reintroduces underscore stripping passes the whole suite.

Two example names described a rule #53 deleted. 'strips slashes from column name' and 'strips other special characters from column name' passed only because 'Country / Region' and 'Price (USD)' happen to collapse the same way under both rules — parameterize substitutes and collapses, it never strips. Seven lines below, 'keeps a hyphen, the way parameterize does' asserts the opposite. Renamed to say what they check.

CHANGELOG had no entry for #53 at all, in a released gem whose README commits to Keep a Changelog and SemVer. Added, including the selectors that change for a consumer on bundle update — col-email → col-e-mail, col-customers_name → col-customer_s_name, col-fullname → col-full_name — and the note that an app carrying a pre-normalizing workaround should drop it.

Suite

38 examples, 0 failures. The new guard is covered both ways; require 'capybara/active_admin/selectors' in isolation now works.

Not in this PR

The review found three more label→DOM-name derivations still hand-rolled, each the same bug class #53 fixed, and all pre-existing rather than introduced by it:

  • Util.parse_model_name — gsub(' ', '_').downcase.singularize vs ActiveModel's underscore.tr('/', '_'). have_attributes_table(model: 'Billing::Employee') builds div.attributes_table.billing::employee and raises Nokogiri::CSS::SyntaxError; the gratuitous singularize also breaks Statistics → statistic.
  • table_selector — same, against active_admin_config.resource_name.plural. Needs its own derivation, not parse_model_name: the table id comes from the registered resource name while the attributes-table class comes from the model class.
  • table_scope_selector — a bare li.scope, while ActiveAdmin::Scope#initialize sets @id = name.to_s.parameterize(separator: '_') and renders it as a class. have_table_scope matches on exact_text: instead, which cannot work with AA's default scopes_show_count, since the li text includes the count.

Worth a separate PR; happy to open it.

Follow-up to #53, from a review of it.

`parameterize` transliterates to Latin and drops the rest, so 'Имя',
'名前', '№' and a blank string all come back as ''. ActiveAdmin makes the
same call, so it renders `class="row row-"` for every one of them — and
#53 made the gem build `tr.row.row-` to match, which matches them all at
once:

    attributes_row_selector('Имя')      => tr.row.row- > td
    attributes_row_selector('Телефон')  => tr.row.row- > td

On a page with both rows, `have_attribute_row('Имя', exact_text: ...)`
passes against the phone row, and `within_attribute_row('Имя')` raises
Capybara::Ambiguous. The derivation #53 replaced produced `row-имя`,
matched nothing, and failed honestly. Turning a loud failure into a
silent wrong match is the worse half of the trade, so the helper now
raises with the label named and a suggestion to match on text.

Also here, all from the same review:

  * `selectors/table.rb` and `selectors/attributes_table.rb` reference
    `Util` without requiring it. They load today only because
    `capybara/active_admin.rb` happens to require util one line earlier;
    requiring the selectors file directly raised NameError.

  * `table_header_selector(nil, column: 'x')` returned the generic
    header selector, discarding the override — so `find_table_header`
    matched the first header on the page whatever column was asked for.

  * `table_cell_selector(:full_name)` had no example, though that is the
    commonest input of all and the old derivation broke it
    (`col-fullname`). Added, plus examples for the new guard.

  * Two example names still said "strips slashes" / "strips other
    special characters", the rule #53 deleted — they passed only because
    those two labels collapse the same way under both. A maintainer
    reading them as the contract would reinstate the stripping.

  * CHANGELOG had nothing for #53 at all, in a released gem whose README
    commits to Keep a Changelog, including the selector changes a
    consumer needs to know about on `bundle update`.
@Fivell
Fivell merged commit 2a25e56 into master Oct 1, 2026
25 checks passed
@Fivell
Fivell deleted the fix/blank-css-class-guard branch October 1, 2026 13:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant