Skip to content

Derive CSS classes the way ActiveAdmin does - #53

Merged
Fivell merged 2 commits into
masterfrom
fix/match-activeadmin-css-class-derivation
Oct 1, 2026
Merged

Fivell merged 2 commits into
masterfrom
fix/match-activeadmin-css-class-derivation

Conversation

@Fivell

@Fivell Fivell commented Oct 1, 2026

Copy link
Copy Markdown
Member

The bug

Three selectors each hand-rolled their own approximation of how ActiveAdmin turns a label into a CSS class:

attributes_row_selector   label.gsub(/[\s\/]/, '_').downcase
table_header_selector     text.tr(' ', '_').downcase
table_cell_selector       text.downcase.gsub(/[^a-z0-9\s]/, '').gsub(/\s+/, '_').squeeze('_')

ActiveAdmin does none of those. It calls parameterize(separator: '_'):

  • AttributesTable#row — attributes_table.rb:30 — classes << "row-#{title.to_s.parameterize(separator: "_")}"
  • TableFor::Column#html_class — table_for.rb:152 — html_classes << "col-#{@title.to_s.parameterize(separator: "_")}"

So all three drift from the markup that was actually rendered, each in its own direction:

label ActiveAdmin renders attributes_row_selector table_cell_selector
'VAT / TAX Number' vat_tax_number vat___tax_number ✗ vat_tax_number
'E-mail' e-mail e-mail email ✗
"Customer's Name" customer_s_name customer's_name ✗ customers_name ✗
'# of DIDs' of_dids #_of_dids ✗ _of_dids ✗
'Setup (one-time)' setup_one-time setup_(one-time) ✗ setup_onetime ✗
'Price, USD' price_usd price,_usd ✗ price_usd
'DID Number' did_number did__number ✗ did_number
'ISO 3166-1' iso_3166-1 iso_3166-1 iso_31661 ✗

The failure is silent in the worst way: have_table_cell(column: 'E-mail') just doesn't match, and the spec reads as "the cell is missing".

This has already cost downstream users a workaround. #43 is the same bug seen from the table_cell_selector side, and at least one app carries a helper that pre-normalizes labels before handing them to the gem — which then breaks again when the gem's own normalization changes, because the current table_cell_selector strips _ along with everything else.

The fix

One helper that makes the same call ActiveAdmin makes:

def css_class_for_label(label)
  label.to_s.parameterize(separator: '_')
end

All three selectors route through it, so a label can only ever produce the class that was rendered. parameterize is idempotent on its own output, so apps that pre-normalize as a workaround keep working and can drop it at their leisure.

Verification

Unit examples for the labels in the table above, in both spec/lib/selectors/table_spec.rb and spec/lib/selectors/attributes_table_spec.rb.

End to end, the dummy's show page now has a deliberately awkward label:

row('VAT / TAX Number') { |r| r.salary }

and spec/system/business_employee_show_spec.rb asserts the gem finds it. With the previous derivation restored, that spec fails against the real rendered page:

expected to find css "tr.row.row-vat___tax_number > td" but there were no matches

Full suite: 27 examples, 0 failures.

Compatibility

Only labels that the old code mapped to a class ActiveAdmin never rendered change behaviour — those lookups were already finding nothing. Plain labels ('Full name', 'ID', 'Country / Region', 'Price (USD)') are unaffected; the existing examples for them pass untouched.

Three selectors each hand-rolled their own approximation of how
ActiveAdmin turns a label into a CSS class:

    attributes_row_selector   label.gsub(/[\s\/]/, '_').downcase
    table_header_selector     text.tr(' ', '_').downcase
    table_cell_selector       text.downcase.gsub(/[^a-z0-9\s]/, '')
                                  .gsub(/\s+/, '_').squeeze('_')

ActiveAdmin does neither. It calls `parameterize(separator: '_')` --
`AttributesTable#row` (attributes_table.rb:30) and
`TableFor::Column#html_class` (table_for.rb:152) -- so the three drift
from the rendered markup, each differently:

    label               ActiveAdmin      row selector      cell selector
    'VAT / TAX Number'  vat_tax_number   vat___tax_number  vat_tax_number
    'E-mail'            e-mail           e-mail            email
    "Customer's Name"   customer_s_name  customer's_name   customers_name
    '# of DIDs'         of_dids          #_of_dids         _of_dids
    'Price, USD'        price_usd        price,_usd        price_usd
    'DID  Number'       did_number       did__number       did_number

Route all three through `Util.css_class_for_label`, which makes the
same call ActiveAdmin makes, so a label can only ever produce the class
that was actually rendered.

Covered by unit examples for the labels above and, end to end, by a
`row('VAT / TAX Number')` added to the dummy's show page: with the old
derivation that spec looks for `tr.row.row-vat___tax_number` and finds
nothing.
@Fivell
Fivell requested review from senid231 and a balanced review from Copilot October 1, 2026 08:56

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The changed table-header normalization path lacks direct or system-level test coverage.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Aligns label-derived selectors with ActiveAdmin’s parameterize(separator: '_') behavior.

Changes:

  • Adds shared label-to-CSS-class normalization.
  • Updates table and attribute-row selectors.
  • Adds unit and system regression coverage.
File Description
lib/​capybara/​active_admin/​util.rb Adds shared normalization helper.
lib/​capybara/​active_admin/​selectors/​table.rb Normalizes table header and cell classes.
lib/​capybara/​active_admin/​selectors/​attributes_table.rb Normalizes attribute-row classes.
spec/​lib/​selectors/​table_spec.rb Covers punctuation in cell labels.
spec/​lib/​selectors/​attributes_table_spec.rb Covers separator normalization.
spec/​dummy/​test_application.rb Adds an awkward rendered label.
spec/​system/​business_employee_show_spec.rb Verifies matching against rendered markup.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lib/capybara/active_admin/selectors/table.rb
The third selector this PR rewrote had no examples at all — not one
table_header_selector or have_table_header anywhere in the suite — so
its normalization could have regressed unnoticed while the cell and
attribute-row cases stayed green.

Seven examples: generic form, a plain label, the three labels where the
previous `tr(' ', '_').downcase` drifted from parameterize, the
`column:` override taking the same path as the visible text, and the
sortable/sorted suffixes still being appended.

Restoring the old implementation fails four of them:

    expected: col-vat_tax_number     got: col-vat_/_tax_number
    expected: col-customer_s_name    got: col-customer's_name
    expected: col-of_dids            got: col-#_of_dids

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

The changed table-header normalization path lacks regression coverage.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)

@Fivell
Fivell merged commit 4b9fecc into master Oct 1, 2026
37 checks passed
@Fivell
Fivell deleted the fix/match-activeadmin-css-class-derivation branch October 1, 2026 12:36
Fivell added a commit that referenced this pull request Oct 1, 2026
…#55)

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 added a commit that referenced this pull request Oct 1, 2026
Follow-up to #53, from the same review. #53 fixed how a *label* becomes
a CSS class; these are the two remaining derivations of the same kind,
where a *model* becomes one.

Arbre builds a record's DOM name from `record.class.model_name.singular`
(html/tag.rb:164-168) and ActiveAdmin builds the index table id from
`active_admin_config.resource_name.plural` (index_as_table.rb:240) —
both of which underscore a namespace. The gem used
`gsub(' ', '_').downcase` plus an unconditional singularize/pluralize,
which leaves the `::` alone:

    have_attributes_table(model: 'Billing::Employee')
      => div.attributes_table.billing::employee
    have_table(resource_name: Billing::Employee)
      => table#index_table_billing::employees

Neither is a selector. Under cuprite the first aborts the example with
Ferrum::JavaScriptError, under rack_test with Nokogiri::CSS::SyntaxError
— in both cases with nothing naming the cause. `table_selector` also had
no class branch at all, so passing a model class was broken for every
namespaced model.

Verified against real rendered pages: the dummy renders
`<div class="attributes_table billing_employee">` and
`<table id="index_table_business_employees">`, and the new system spec
asserts both. Restoring the old derivation fails it.

Note the two names have different sources: the table id follows the
name a resource was *registered* under (`as: 'Business Employee'`), so a
renamed resource cannot be addressed by its class. Documented at the
call site rather than papered over.

Also fixes six dead `have_table_cell` examples in README.md, which still
showed a positional-text signature the matcher has never had — they
raise TypeError or ArgumentError as written.
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.

3 participants