Skip to content

Commit 7e3a33e

Browse files
committed
Initial release: footer DSL for ActiveAdmin index tables
- index footer_data: ->(c) { ... } — one SQL for all aggregates - column :x, footer: -> | :sum | string — declarative per-column footers - Auto-strips LIMIT/OFFSET/ORDER so totals span all pages, not just visible - Symbol aggregators fall back to Enumerable for plain-Array collections - Compatible with ActiveAdmin 3.5+ and 4.x via Tailwind/Sass adapters - Dummy app + Capybara specs (28 examples on each AA version) - CI matrix: Ruby 3.2/3.3 x AA 3.5 / 4.0.0.beta22
0 parents  commit 7e3a33e

50 files changed

Lines changed: 1313 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [master, main]
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
strategy:
12+
fail-fast: false
13+
matrix:
14+
ruby: ["3.2", "3.3"]
15+
gemfile:
16+
- gemfiles/activeadmin_3.5.gemfile
17+
- gemfiles/activeadmin_4.0.gemfile
18+
env:
19+
BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}
20+
steps:
21+
- uses: actions/checkout@v4
22+
- uses: ruby/setup-ruby@v1
23+
with:
24+
ruby-version: ${{ matrix.ruby }}
25+
bundler-cache: true
26+
- run: bundle exec rspec

.gitignore

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/.bundle/
2+
/Gemfile.lock
3+
/gemfiles/*.lock
4+
/pkg/
5+
/spec/.rspec-results
6+
/spec/dummy/db/*.sqlite3
7+
/spec/dummy/log/*.log
8+
/spec/dummy/tmp/
9+
/spec/dummy/storage/
10+
# Auto-compiled by spec/support/tailwind_setup.rb when AA 4 is loaded —
11+
# active_admin.scss is the AA-3 source of truth and is committed.
12+
/spec/dummy/app/assets/stylesheets/active_admin.css
13+
/coverage/
14+
*.gem
15+
.byebug_history

.rspec

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
--color
2+
--require spec_helper
3+
--format documentation

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Changelog
2+
3+
## [Unreleased]
4+
5+
### Added
6+
- Initial release. Adds `footer:` column option and `footer_data:` index option
7+
to ActiveAdmin index tables.

Gemfile

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# frozen_string_literal: true
2+
3+
source "https://rubygems.org"
4+
5+
gemspec
6+
7+
gem "rake"
8+
9+
group :test do
10+
gem "rspec-rails", "~> 7.0"
11+
gem "capybara"
12+
gem "capybara_active_admin"
13+
gem "sqlite3"
14+
gem "puma"
15+
gem "sassc-rails"
16+
gem "actionmailer"
17+
gem "selenium-webdriver", "~> 4.20"
18+
end

LICENSE.txt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Igor Fedoronchuk
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
# activeadmin_table_footer
2+
3+
Adds a `<tfoot>` row to ActiveAdmin index tables with a clean per-column DSL
4+
and an optional single-query aggregate (`footer_data`) shared across cells.
5+
6+
**Compatible with ActiveAdmin 3.5+ and 4.x.**
7+
8+
## Why
9+
10+
Real-world admin tables for billing, subscriptions, orders, inventory often
11+
need a totals row at the bottom:
12+
13+
| | Customer | Plan | Seats | Total Cost |
14+
|---|---|---|---|---|
15+
| | Acme | Basic | 10 | $100.00 |
16+
| | Globex | Pro | 7 | $175.00 |
17+
| **Total** | | | **17** | **$275.00** |
18+
19+
The naive workaround — one `Proc` per column that calls `collection.sum(:x)`
20+
produces N SQL queries (one per footer cell). This gem solves both problems:
21+
22+
1. **DSL**: `column :amount, footer: -> { ... }` — declarative, lives next to
23+
the column it footers.
24+
2. **One query**: `index footer_data: ->(c) { ... }` runs once and exposes the
25+
result inside every `footer:` proc as the `footer_data` method.
26+
27+
## Installation
28+
29+
```ruby
30+
# Gemfile
31+
gem "activeadmin_table_footer"
32+
```
33+
34+
The gem auto-registers via a Rails engine — no initializer needed unless you
35+
want to override styles.
36+
37+
## Usage
38+
39+
### Minimal (per-column aggregates)
40+
41+
```ruby
42+
ActiveAdmin.register Order do
43+
index do
44+
column :customer
45+
column :placed_at
46+
column :amount, footer: :sum # → collection.sum(:amount)
47+
column :tax, footer: :sum
48+
column :status, footer: -> { strong { "All Orders" } }
49+
end
50+
end
51+
```
52+
53+
### Production (single SQL for all aggregates)
54+
55+
The `collection` passed into both `footer_data:` and `column ... footer:` Procs
56+
is automatically stripped of `LIMIT / OFFSET / ORDER BY` so aggregates cover
57+
**all filtered rows** across every page, not just the visible 30.
58+
59+
```ruby
60+
ActiveAdmin.register Subscription do
61+
index footer_data: ->(collection) {
62+
totals = collection.joins(:plan).pick(
63+
Arel.sql("COALESCE(SUM(seats), 0)"),
64+
Arel.sql("COALESCE(SUM(seats * plans.monthly_price), 0)")
65+
)
66+
{ total_seats: totals[0], total_cost: totals[1] }
67+
} do
68+
column :customer
69+
column :plan
70+
column :is_operator, footer: -> { strong { "Total (all pages)" } }
71+
column "Seats", footer: -> { strong { footer_data[:total_seats].to_s } }, &:seats
72+
column :total_cost,
73+
footer: -> { strong { number_to_currency(footer_data[:total_cost]) } } do |row|
74+
number_to_currency row.total_cost
75+
end
76+
end
77+
end
78+
```
79+
80+
One SQL query computes both `SUM(seats)` and `SUM(seats × plans.monthly_price)`;
81+
each `footer:` Proc reads from `footer_data` instead of querying again.
82+
83+
The footer **reflects active filters** — Ransack-narrowed `collection` is the
84+
one passed to `footer_data:`. Filter by customer, plan, status → totals update.
85+
86+
## DSL
87+
88+
### `index footer_data: <Proc>`
89+
90+
A Proc that receives the **filtered, un-paginated** collection (the AA-scoped
91+
relation with `LIMIT / OFFSET / ORDER BY` stripped) and returns any value
92+
(Hash, Array, OpenStruct). The return value is memoized for the request and
93+
exposed inside every `footer:` Proc as the `footer_data` method.
94+
95+
### `column …, footer: <value>`
96+
97+
| `footer:` value | Behavior |
98+
|---|---|
99+
| `String` / Numeric | Rendered as-is (`footer.to_s`) |
100+
| `Symbol` (`:sum`, `:count`, `:average`, `:minimum`, `:maximum`) | AR relation → one SQL per cell (use sparingly; prefer `footer_data:`). Plain Array → Ruby `Enumerable` equivalent (nil-safe). |
101+
| `Proc` (arity 0) | `instance_exec`'d in the table view. View helpers (`number_to_currency`, `l`, `link_to`), Arbre tags (`strong`, `span`), and `footer_data` all work. |
102+
| `Proc` (arity 1) | `instance_exec`'d with the unscoped collection as argument. |
103+
| `Arbre::Element` | Inserted directly (e.g. `column footer: ->{ link_to('Export', export_path) }`) |
104+
105+
### Plain-Array collections
106+
107+
`table_for` can be invoked with a hand-rolled Array (in a custom panel, `show`
108+
page, etc.) rather than an AR relation. All footer forms work in both modes —
109+
`Symbol` aggregators auto-fall back to `Enumerable` when the collection is not
110+
an AR relation:
111+
112+
```ruby
113+
items = [
114+
Item.new(name: "Widget", qty: 10, price: 5),
115+
Item.new(name: "Gadget", qty: 4, price: 20),
116+
Item.new(name: "Sprocket", qty: 6, price: 12)
117+
]
118+
119+
table_for items do
120+
column :name, footer: -> { strong { "Totals" } }
121+
column :qty, footer: :sum # → 20
122+
column :price, footer: ->(arr) { number_to_currency(arr.sum(&:price)) }
123+
end
124+
```
125+
126+
Columns without `:footer` get an **empty cell** in the footer row so widths
127+
align with the header.
128+
129+
`<tfoot>` is only rendered when at least one column has `:footer` — tables
130+
without footers are unaffected.
131+
132+
## Styling
133+
134+
`<tfoot> <td>` gets:
135+
136+
- **AA 4 (Tailwind)**: `px-3 py-2 bg-gray-50 dark:bg-gray-800/50 font-semibold border-t border-gray-200 dark:border-gray-700 text-left`
137+
- **AA 3 (Sass)**: no default styling — add to your `active_admin.scss`:
138+
```scss
139+
.index_table tfoot td {
140+
background: #f3f4f6;
141+
font-weight: 600;
142+
border-top: 1px solid #ddd;
143+
padding: 8px 10px;
144+
}
145+
```
146+
147+
Override defaults globally:
148+
149+
```ruby
150+
# config/initializers/activeadmin_table_footer.rb
151+
ActiveadminTableFooter.configure do |c|
152+
c.footer_th_class = "px-4 py-3 bg-blue-50 font-bold"
153+
c.footer_tr_class = ""
154+
end
155+
```
156+
157+
## Testing
158+
159+
Each footer cell carries `td[data-column="<key>"]` AND `td.col.col-<key>` (the
160+
latter for compatibility with [capybara_active_admin](https://github.com/activeadmin-plugins/capybara_active_admin)
161+
matchers). Both selectors work in AA 3 and AA 4:
162+
163+
```ruby
164+
within_table_for("subscriptions") do
165+
within_table_footer do
166+
expect(page).to have_table_cell(column: "Seats", exact_text: "17")
167+
expect(page).to have_table_cell(column: "Total Cost", exact_text: "$275.00")
168+
end
169+
end
170+
```
171+
172+
## Use cases
173+
174+
| Domain | Example |
175+
|---|---|
176+
| Billing / subscriptions | SUM(seats × monthly_price) across all pages |
177+
| Orders | COUNT(*), SUM(amount), SUM(tax), MAX(placed_at) |
178+
| Inventory | SUM(qty), SUM(qty × unit_cost) |
179+
| CDR / call records | SUM(duration), SUM(cost), COUNT(DISTINCT caller) |
180+
| Time tracking | SUM(hours), SUM(hours × hourly_rate) |
181+
| Refunds | COUNT, SUM, group-by-reason in a single query |
182+
183+
## How it works
184+
185+
The gem `prepend`s two modules in a `Rails::Engine` `to_prepare` block:
186+
187+
- `ActiveadminTableFooter::TableForExtension``ActiveAdmin::Views::TableFor`:
188+
extracts `footer_data:` option, lazily builds `<tfoot>` when the first column
189+
with `:footer` is added, back-fills empty cells for prior columns so widths
190+
align with `<thead>`.
191+
- `ActiveadminTableFooter::IndexAsTableExtension``ActiveAdmin::Views::IndexAsTable`:
192+
wraps the user's index block so `@footer_data_proc` is forwarded to the
193+
`TableFor` instance before columns are evaluated.
194+
195+
No changes to ActiveAdmin internals, no monkey-patching beyond `prepend`.
196+
197+
## License
198+
199+
MIT

Rakefile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# frozen_string_literal: true
2+
3+
require "bundler/gem_tasks"
4+
require "rspec/core/rake_task"
5+
6+
RSpec::Core::RakeTask.new(:spec)
7+
8+
task default: :spec

activeadmin_table_footer.gemspec

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "lib/activeadmin_table_footer/version"
4+
5+
Gem::Specification.new do |spec|
6+
spec.name = "activeadmin_table_footer"
7+
spec.version = ActiveadminTableFooter::VERSION
8+
spec.authors = ["Igor Fedoronchuk"]
9+
spec.email = ["fedoronchuk@gmail.com"]
10+
11+
spec.summary = "Table footer DSL for ActiveAdmin index tables"
12+
spec.description = "Adds a `footer:` option to columns and a top-level `footer_data:` proc " \
13+
"so index tables can render a <tfoot> with aggregated values in a single SQL query."
14+
spec.homepage = "https://github.com/activeadmin-plugins/activeadmin_table_footer"
15+
spec.license = "MIT"
16+
17+
spec.required_ruby_version = ">= 3.1"
18+
19+
spec.metadata["homepage_uri"] = spec.homepage
20+
spec.metadata["source_code_uri"] = spec.homepage
21+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md"
22+
23+
spec.files = Dir["lib/**/*", "README.md", "CHANGELOG.md", "LICENSE.txt"]
24+
spec.require_paths = ["lib"]
25+
26+
spec.add_dependency "activeadmin", ">= 3.5", "< 5.0"
27+
spec.add_dependency "arbre", ">= 1.4", "< 3.0"
28+
spec.add_dependency "railties", ">= 7.0"
29+
end

gemfiles/activeadmin_3.5.gemfile

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# frozen_string_literal: true
2+
3+
source "https://rubygems.org"
4+
5+
gemspec path: ".."
6+
7+
gem "activeadmin", "~> 3.5.0"
8+
gem "rake"
9+
10+
group :test do
11+
gem "rspec-rails", "~> 7.0"
12+
gem "capybara"
13+
gem "capybara_active_admin"
14+
gem "sqlite3"
15+
gem "puma"
16+
gem "sassc-rails"
17+
gem "actionmailer"
18+
gem "selenium-webdriver", "~> 4.20"
19+
end

0 commit comments

Comments
 (0)