|
| 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 |
0 commit comments