activeadmin_table_footer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3fa5ab83f8cc3ef9268b501ee4fd09d7fb3d1b6cab32b83dccc80a888bdec44e
4
+ data.tar.gz: 506c5310ec7706b0314641334ab4558b92a2cdcd37e9d861fb6b6ee17adf1c73
5
+ SHA512:
6
+ metadata.gz: c415a857de380ae03c0f90f3d0d47ecfb2b55fa7e8569f009bc6f4b550127ac11b3a39911e8f785738d03227abaeed6d614be605acd28b04d4cc2d0bc926c946
7
+ data.tar.gz: 9498192db2a62f42d2f52f9e68a6e2aa038d4c2c9e5448e450128c4c4cb43cfecaa5ed4dab29c9dabf039c375ab9bb46ef58c295adb7bd5c7a9104dafb075653
data/LICENSE.txt ADDED
@@ -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.
data/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # activeadmin_table_footer
2
+
3
+ ![CI](https://github.com/activeadmin-plugins/activeadmin_table_footer/workflows/CI/badge.svg)
4
+ ![Coverage](https://img.shields.io/endpoint?url=https://activeadmin-plugins.github.io/activeadmin_table_footer/badge.json)
5
+ ![Ruby](https://img.shields.io/badge/ruby-3.3%2B-blue)
6
+
7
+ Adds a `<tfoot>` row to ActiveAdmin index tables. Totals are aggregated
8
+ across **all pages** of the filtered scope — not just the visible one — in a
9
+ single SQL query when you use `footer_data:`.
10
+
11
+ Works with **ActiveAdmin 3.5+ and 4.x**.
12
+
13
+ ### ActiveAdmin 4
14
+
15
+ ![Subscriptions index with footer totals on AA 4](https://github.com/activeadmin-plugins/activeadmin_table_footer/releases/download/assets-v1/subscriptions_with_footer_4_0.png)
16
+
17
+ ### ActiveAdmin 3
18
+
19
+ ![Subscriptions index with footer totals on AA 3.5](https://github.com/activeadmin-plugins/activeadmin_table_footer/releases/download/assets-v1/subscriptions_with_footer_3_5.png)
20
+
21
+ The page shows 30 rows, but the footer row reports the sum across all 42
22
+ subscriptions — that's the point.
23
+
24
+ ## Install
25
+
26
+ ```ruby
27
+ # Gemfile
28
+ gem "activeadmin_table_footer"
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```ruby
34
+ ActiveAdmin.register Subscription do
35
+ index footer_data: ->(collection) {
36
+ totals = collection.joins(:plan).pick(
37
+ Arel.sql("COALESCE(SUM(seats), 0)"),
38
+ Arel.sql("COALESCE(SUM(seats * plans.monthly_price), 0)")
39
+ )
40
+ { total_seats: totals[0], total_cost: totals[1] }
41
+ } do
42
+ column :customer
43
+ column :plan
44
+ column :is_operator, footer: -> { strong { "Total (all pages)" } }
45
+ column "Seats", footer: -> { strong { footer_data[:total_seats].to_s } }, &:seats
46
+ column :total_cost, footer: -> { strong { number_to_currency(footer_data[:total_cost]) } } do |row|
47
+ number_to_currency row.total_cost
48
+ end
49
+ end
50
+ end
51
+ ```
52
+
53
+ The `footer_data:` Proc runs once over the filtered scope (LIMIT/OFFSET/ORDER
54
+ are stripped automatically). The result is exposed inside each
55
+ `column …, footer: …` Proc via `footer_data`.
56
+
57
+ `footer:` accepts a string, a symbol (`:sum`, `:count`, `:average`,
58
+ `:minimum`, `:maximum`), or a Proc — Procs run inside the table view, so view
59
+ helpers (`number_to_currency`, `link_to`) and Arbre tags (`strong`, `span`)
60
+ work as expected.
61
+
62
+ ## License
63
+
64
+ MIT
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module ActiveadminTableFooter
6
+ class Engine < ::Rails::Engine
7
+ config.to_prepare do
8
+ require "activeadmin_table_footer/table_for_extension"
9
+ require "activeadmin_table_footer/index_as_table_extension"
10
+
11
+ ActiveAdmin::Views::TableFor.prepend(ActiveadminTableFooter::TableForExtension)
12
+ ActiveAdmin::Views::IndexAsTable.prepend(ActiveadminTableFooter::IndexAsTableExtension)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveadminTableFooter
4
+ # IndexAsTable#build constructs its own table_options hash and does not pass
5
+ # unknown options through. We wrap the user block so the TableFor instance
6
+ # receives @footer_data_proc before columns are evaluated.
7
+ module IndexAsTableExtension
8
+ def build(page_presenter, collection)
9
+ footer_proc = page_presenter[:footer_data]
10
+
11
+ if footer_proc && page_presenter.block
12
+ original_block = page_presenter.block
13
+ wrapped = lambda do |table|
14
+ table.instance_variable_set(:@footer_data_proc, footer_proc)
15
+ instance_exec(table, &original_block)
16
+ end
17
+ page_presenter.instance_variable_set(:@block, wrapped)
18
+ end
19
+
20
+ super
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveadminTableFooter
4
+ module Styles
5
+ TAILWIND_TH = "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"
6
+
7
+ module_function
8
+
9
+ def aa_v4?
10
+ Gem::Version.new(ActiveAdmin::VERSION) >= Gem::Version.new("4.0.0.beta1")
11
+ end
12
+
13
+ def footer_th_class
14
+ aa_v4? ? TAILWIND_TH : ""
15
+ end
16
+
17
+ def footer_tr_class
18
+ aa_v4? ? "" : "footer"
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveadminTableFooter
4
+ module TableForExtension
5
+ def build(obj, *attrs)
6
+ options = attrs.extract_options!
7
+ @footer_data_proc = options.delete(:footer_data)
8
+ super(obj, *attrs, options)
9
+ end
10
+
11
+ def footer_data
12
+ return @footer_data if defined?(@footer_data)
13
+ return (@footer_data = nil) unless @footer_data_proc
14
+ @footer_data = @footer_data_proc.call(unscoped_collection_for_footer)
15
+ end
16
+
17
+ # The collection AA passes us is the paginated + ordered slice. Aggregates
18
+ # over "all rows" need the underlying relation without LIMIT/OFFSET/ORDER —
19
+ # otherwise SUM/COUNT on page 2 would only see the page-2 slice and ORDER
20
+ # confuses some aggregate queries.
21
+ def unscoped_collection_for_footer
22
+ return @collection unless @collection.respond_to?(:except)
23
+ @collection.except(:limit, :offset, :order)
24
+ end
25
+
26
+ def column(*args, &block)
27
+ super
28
+ col = @columns.last
29
+
30
+ if @aatf_footer_row
31
+ # Tfoot already exists — every subsequent column gets a cell
32
+ # (with content if it has :footer, empty otherwise) so columns align.
33
+ build_footer_cell_for(col)
34
+ elsif column_has_footer?(col)
35
+ # First column with :footer — open tfoot and back-fill empty cells
36
+ # for all previously-added columns so the row aligns with headers.
37
+ ensure_tfoot!
38
+ @columns[0...-1].each { |prior| build_footer_cell_for(prior) }
39
+ build_footer_cell_for(col)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def column_has_footer?(col)
46
+ col.instance_variable_get(:@options).key?(:footer)
47
+ end
48
+
49
+ def build_footer_cell_for(col)
50
+ within @aatf_footer_row do
51
+ column_key = column_key_for(col)
52
+ # Always include `col col-<key>` so capybara_active_admin matchers
53
+ # (`have_table_cell(column: ...)`) work in both AA 3 and AA 4 — the
54
+ # selector convention is `td.col.col-<key>`.
55
+ compat_classes = column_key ? "col col-#{column_key}" : "col"
56
+ classes = [col.html_class, compat_classes, ActiveadminTableFooter.footer_th_class]
57
+ .reject { |c| c.nil? || c.to_s.empty? }
58
+ .join(" ")
59
+ attrs = { class: classes.empty? ? nil : classes }
60
+ attrs[:"data-column"] = column_key if column_key
61
+ td(**attrs) do
62
+ render_footer_value(col) if column_has_footer?(col)
63
+ end
64
+ end
65
+ end
66
+
67
+ def column_key_for(col)
68
+ # AA 4 exposes Column#title_id; AA 3 does not — derive from title.
69
+ if col.respond_to?(:title_id) && col.title_id.respond_to?(:presence)
70
+ return col.title_id.presence
71
+ end
72
+ title = col.title.to_s
73
+ return nil if title.empty?
74
+ title.parameterize(separator: "_")
75
+ end
76
+
77
+ def ensure_tfoot!
78
+ return if @aatf_footer_row
79
+ tfoot_classes = ActiveadminTableFooter.footer_tr_class
80
+ tfoot do
81
+ @aatf_footer_row = tr(class: tfoot_classes.presence)
82
+ end
83
+ end
84
+
85
+ def render_footer_value(col)
86
+ footer = col.instance_variable_get(:@options)[:footer]
87
+ case footer
88
+ when nil
89
+ nil
90
+ when Symbol
91
+ text_node aggregate_collection(footer, col.data).to_s
92
+ when Proc
93
+ arg = unscoped_collection_for_footer
94
+ result = footer.arity == 0 ? instance_exec(&footer) : instance_exec(arg, &footer)
95
+ text_node(result.to_s) unless result.is_a?(Arbre::Element)
96
+ else
97
+ text_node footer.to_s
98
+ end
99
+ end
100
+
101
+ # Symbol footer (`:sum`, `:count`, ...) works for both AR relations and
102
+ # plain Arrays. AR uses native SQL aggregates; Arrays fall back to
103
+ # in-Ruby Enumerable equivalents.
104
+ def aggregate_collection(method, attribute)
105
+ scope = unscoped_collection_for_footer
106
+ if ar_relation?(scope)
107
+ scope.public_send(method, attribute)
108
+ else
109
+ values = Array(scope).map { |r| r.public_send(attribute) }.compact
110
+ case method
111
+ when :sum then values.sum
112
+ when :count then values.size
113
+ when :average then values.empty? ? 0 : values.sum.to_f / values.size
114
+ when :minimum then values.min
115
+ when :maximum then values.max
116
+ else scope.public_send(method, attribute)
117
+ end
118
+ end
119
+ end
120
+
121
+ def ar_relation?(scope)
122
+ defined?(ActiveRecord::Relation) && scope.is_a?(ActiveRecord::Relation)
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveadminTableFooter
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "activeadmin_table_footer/version"
4
+ require "activeadmin_table_footer/styles"
5
+
6
+ module ActiveadminTableFooter
7
+ class << self
8
+ attr_writer :footer_th_class, :footer_tr_class
9
+
10
+ def footer_th_class
11
+ @footer_th_class || Styles.footer_th_class
12
+ end
13
+
14
+ def footer_tr_class
15
+ @footer_tr_class || Styles.footer_tr_class
16
+ end
17
+
18
+ def configure
19
+ yield self
20
+ end
21
+ end
22
+ end
23
+
24
+ require "activeadmin_table_footer/engine" if defined?(Rails)
metadata ADDED
@@ -0,0 +1,106 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activeadmin_table_footer
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Igor Fedoronchuk
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activeadmin
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.5'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '5.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '3.5'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '5.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: arbre
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '1.4'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '3.0'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '1.4'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '3.0'
52
+ - !ruby/object:Gem::Dependency
53
+ name: railties
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '7.0'
59
+ type: :runtime
60
+ prerelease: false
61
+ version_requirements: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '7.0'
66
+ description: Adds a `footer:` option to columns and a top-level `footer_data:` proc
67
+ so index tables can render a <tfoot> with aggregated values in a single SQL query.
68
+ email:
69
+ - fedoronchuk@gmail.com
70
+ executables: []
71
+ extensions: []
72
+ extra_rdoc_files: []
73
+ files:
74
+ - LICENSE.txt
75
+ - README.md
76
+ - lib/activeadmin_table_footer.rb
77
+ - lib/activeadmin_table_footer/engine.rb
78
+ - lib/activeadmin_table_footer/index_as_table_extension.rb
79
+ - lib/activeadmin_table_footer/styles.rb
80
+ - lib/activeadmin_table_footer/table_for_extension.rb
81
+ - lib/activeadmin_table_footer/version.rb
82
+ homepage: https://github.com/activeadmin-plugins/activeadmin_table_footer
83
+ licenses:
84
+ - MIT
85
+ metadata:
86
+ homepage_uri: https://github.com/activeadmin-plugins/activeadmin_table_footer
87
+ source_code_uri: https://github.com/activeadmin-plugins/activeadmin_table_footer
88
+ changelog_uri: https://github.com/activeadmin-plugins/activeadmin_table_footer/releases
89
+ rdoc_options: []
90
+ require_paths:
91
+ - lib
92
+ required_ruby_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '3.1'
97
+ required_rubygems_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ requirements: []
103
+ rubygems_version: 3.7.1
104
+ specification_version: 4
105
+ summary: Table footer DSL for ActiveAdmin index tables
106
+ test_files: []