data-table 1.0.1 → 2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +4 -0
- data/.rspec +2 -0
- data/.travis.yml +4 -0
- data/Guardfile +48 -0
- data/README.md +128 -12
- data/Rakefile +5 -0
- data/assigments_table.html +56 -0
- data/data-table.gemspec +19 -12
- data/examples/all_features.rb +161 -0
- data/lib/data-table.rb +62 -3
- data/lib/data-table/column.rb +75 -0
- data/lib/data-table/enum.rb +57 -0
- data/lib/data-table/table.rb +443 -0
- data/lib/data-table/version.rb +2 -2
- data/rebuild_gem.rb +11 -0
- data/spec/column_spec.rb +41 -0
- data/spec/data_table_spec.rb +22 -0
- data/spec/enum_spec.rb +36 -0
- data/spec/spec_helper.rb +18 -0
- data/spec/table_spec.rb +100 -0
- metadata +91 -17
- data/lib/data-table/data_table.rb +0 -385
- data/lib/data-table/data_table_column.rb +0 -60
data/lib/data-table.rb
CHANGED
@@ -1,3 +1,62 @@
|
|
1
|
-
|
2
|
-
require
|
3
|
-
require
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require 'data-table/version'
|
3
|
+
require 'data-table/table'
|
4
|
+
require 'data-table/column'
|
5
|
+
require 'data-table/enum'
|
6
|
+
|
7
|
+
module DataTable
|
8
|
+
def self.render(collection, &_blk)
|
9
|
+
# make a new table
|
10
|
+
t = DataTable::Table.new(collection)
|
11
|
+
|
12
|
+
# yield it to the block for configuration
|
13
|
+
yield t
|
14
|
+
|
15
|
+
# modify the data structure if necessary and do calculations
|
16
|
+
t.prepare_data
|
17
|
+
|
18
|
+
# render the table
|
19
|
+
t.render
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.default_css_styles
|
23
|
+
<<-CSS_STYLE
|
24
|
+
.data_table {width: 100%; empty-cells: show}
|
25
|
+
.data_table td, .data_table th {padding: 3px}
|
26
|
+
|
27
|
+
.data_table caption {font-size: 2em; font-weight: bold}
|
28
|
+
|
29
|
+
.data_table thead th {background-color: #ddd; border-bottom: 1px solid #bbb;}
|
30
|
+
|
31
|
+
.data_table tbody tr.alt {background-color: #eee;}
|
32
|
+
|
33
|
+
.data_table .group_header th {text-align: left;}
|
34
|
+
|
35
|
+
.data_table .subtotal.index_0 td,
|
36
|
+
.data_table .parent_subtotal.index_0 td
|
37
|
+
{
|
38
|
+
border-top: 1px solid #000;
|
39
|
+
}
|
40
|
+
|
41
|
+
.data_table tfoot .total.index_0 td
|
42
|
+
{
|
43
|
+
border-top: 1px solid #000;
|
44
|
+
}
|
45
|
+
|
46
|
+
.empty_data_table {text-align: center; background-color: #ffc;}
|
47
|
+
|
48
|
+
/* Data Types */
|
49
|
+
.data_table .number, .data_table .money {text-align: right}
|
50
|
+
.data_table .text {text-align: left}
|
51
|
+
|
52
|
+
.level_1,
|
53
|
+
.level_2 {
|
54
|
+
text-align: left
|
55
|
+
}
|
56
|
+
|
57
|
+
.level_2 th {
|
58
|
+
padding-left: 35px;
|
59
|
+
}
|
60
|
+
CSS_STYLE
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module DataTable
|
3
|
+
class Column
|
4
|
+
attr_reader :name
|
5
|
+
attr_accessor :display, :index, :options, :css_class, :attributes
|
6
|
+
|
7
|
+
def initialize(name, description = '', opts = {}, &renderer)
|
8
|
+
@name = name
|
9
|
+
@description = description
|
10
|
+
@data_type = opts[:data_type]
|
11
|
+
@help_text = opts[:help_text]
|
12
|
+
@css_class = opts[:css_class]
|
13
|
+
@attributes = opts[:attributes] || {}
|
14
|
+
@width = opts[:width]
|
15
|
+
@options = opts
|
16
|
+
@display = true
|
17
|
+
@index = 0
|
18
|
+
@renderer = renderer
|
19
|
+
end
|
20
|
+
|
21
|
+
def render_cell(cell_data, row = nil, row_index = 0, col_index = 0)
|
22
|
+
@data_type ||= type(cell_data)
|
23
|
+
|
24
|
+
html = []
|
25
|
+
html << if @renderer && row
|
26
|
+
case @renderer.arity
|
27
|
+
when 1 then @renderer.call(cell_data).to_s
|
28
|
+
when 2 then @renderer.call(cell_data, row).to_s
|
29
|
+
when 3 then @renderer.call(cell_data, row, row_index).to_s
|
30
|
+
when 4 then @renderer.call(cell_data, row, row_index, self).to_s
|
31
|
+
when 5 then @renderer.call(cell_data, row, row_index, self, col_index).to_s
|
32
|
+
end
|
33
|
+
else
|
34
|
+
cell_data.to_s
|
35
|
+
end
|
36
|
+
|
37
|
+
html << '</td>'
|
38
|
+
# Doing this here b/c you can't change @css_class if this is done before the renderer is called
|
39
|
+
"<td class='#{css_class_names}' #{custom_attributes}>" + html.join
|
40
|
+
end
|
41
|
+
|
42
|
+
def render_column_header
|
43
|
+
header = ["<th class='#{css_class_names}' #{custom_attributes}"]
|
44
|
+
header << "title='#{@help_text}' " if @help_text
|
45
|
+
header << "style='width: #{@width}'" if @width
|
46
|
+
header << ">#{@description}</th>"
|
47
|
+
header.join
|
48
|
+
end
|
49
|
+
|
50
|
+
def custom_attributes
|
51
|
+
@attributes.map { |k, v| "#{k}='#{v}'" }.join ' '
|
52
|
+
end
|
53
|
+
|
54
|
+
def css_class_names
|
55
|
+
class_names = []
|
56
|
+
class_names << @name.to_s
|
57
|
+
class_names << @data_type.to_s
|
58
|
+
class_names << @css_class
|
59
|
+
class_names.compact.join(' ')
|
60
|
+
end
|
61
|
+
|
62
|
+
# Set a CSS class name based on data type
|
63
|
+
# For backward compatability, 'string' is renamed to 'text'
|
64
|
+
# For convenience, all Numerics (e.g. Integer, BigDecimal, etc.) just return 'numeric'
|
65
|
+
def type(data)
|
66
|
+
if data.is_a? Numeric
|
67
|
+
'numeric'
|
68
|
+
elsif data.is_a? String
|
69
|
+
'text'
|
70
|
+
else
|
71
|
+
data.class.to_s.downcase
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
# Custom Enumerable methods
|
3
|
+
module Enumerable
|
4
|
+
# Use a set of provided groupings to transform a flat array of
|
5
|
+
# hashes into a nested hash.
|
6
|
+
# groupings should be passed as an array of hashes.
|
7
|
+
# e.g. groupings = [{level: 0}, {level: 1}, {level: 2}]
|
8
|
+
def group_by_recursive(groupings)
|
9
|
+
groups = group_by do |row|
|
10
|
+
row[groupings[0]]
|
11
|
+
end
|
12
|
+
if groupings.count == 1
|
13
|
+
groups
|
14
|
+
else
|
15
|
+
groups.merge(groups) do |_group, elements|
|
16
|
+
elements.group_by_recursive(groupings.drop(1))
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
# Traverse a given nested hash until we reach values that are
|
22
|
+
# not hashes.
|
23
|
+
def each_pair_recursive
|
24
|
+
each_pair do |k, v|
|
25
|
+
if v.is_a?(Hash)
|
26
|
+
v.each_pair_recursive { |i, j| yield i, j }
|
27
|
+
else
|
28
|
+
yield(k, v)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
##
|
34
|
+
# Iterates recusively over a nested collection while keeping track of the
|
35
|
+
# ancestor groups.
|
36
|
+
#
|
37
|
+
# When passing a block with a third variable the parents will be passed to the
|
38
|
+
# block as an array. e.g. ['parent1', 'parent2', 'parent3']
|
39
|
+
#
|
40
|
+
# +limit+ can be passed as an optional integer to limit the depth of recursion.
|
41
|
+
#
|
42
|
+
# +levels+ is internal use and is used to build the array of ancestors
|
43
|
+
#
|
44
|
+
def each_pair_with_parents(limit = 0, levels = nil)
|
45
|
+
levels ||= []
|
46
|
+
each_pair do |k, v|
|
47
|
+
levels << k
|
48
|
+
if v.is_a? Hash
|
49
|
+
v.each_pair_with_parents(limit, levels) { |i, j, next_levels| yield(i, j, next_levels) }
|
50
|
+
elsif v.is_a? Array
|
51
|
+
levels.pop
|
52
|
+
yield(k, v, levels)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
levels.pop
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,443 @@
|
|
1
|
+
module DataTable
|
2
|
+
##
|
3
|
+
# Config Options
|
4
|
+
#
|
5
|
+
# id: the html id
|
6
|
+
# title: the title of the data table
|
7
|
+
# subtitle: the subtitle of the data table
|
8
|
+
# css_class: an extra css class to get applied to the table
|
9
|
+
# empty_text: the text to display of the collection is empty
|
10
|
+
# display_header => false: hide the column headers for the data table
|
11
|
+
# alternate_rows => false: turn off alternating of row css classes
|
12
|
+
# alternate_cols => true: turn on alternating of column classes, defaults to false
|
13
|
+
#
|
14
|
+
# columns: an array of hashes of the column specs for this table
|
15
|
+
#
|
16
|
+
# group_by: an array of columns to group on
|
17
|
+
#
|
18
|
+
# subtotals: an array of hashes that contain the subtotal information for each column that should be subtotaled
|
19
|
+
# totals: an array of hashes that contain the total information for each column that should be totaled
|
20
|
+
#
|
21
|
+
##
|
22
|
+
class Table
|
23
|
+
attr_reader :collection, :grouped_data, :subtotals, :totals,
|
24
|
+
:subtotal_calculations, :total_calculations, :columns
|
25
|
+
|
26
|
+
attr_accessor :id, :title, :css_class, :empty_text,
|
27
|
+
:alternate_rows, :alternate_cols, :display_header, :hide_if_empty,
|
28
|
+
:repeat_headers_for_groups, :custom_headers
|
29
|
+
|
30
|
+
def initialize(collection)
|
31
|
+
@collection = collection
|
32
|
+
@grouped_collection = nil
|
33
|
+
default_options!
|
34
|
+
@columns = []
|
35
|
+
@groupings = []
|
36
|
+
@grouped_data = false
|
37
|
+
@subtotals = []
|
38
|
+
@totals = []
|
39
|
+
end
|
40
|
+
|
41
|
+
def default_options!
|
42
|
+
@id = ''
|
43
|
+
@title = ''
|
44
|
+
@subtitle = ''
|
45
|
+
@css_class = ''
|
46
|
+
@empty_text = 'No records found'
|
47
|
+
@hide_if_empty = false
|
48
|
+
@display_header = true
|
49
|
+
@alternate_rows = true
|
50
|
+
@alternate_cols = false
|
51
|
+
@subtotal_title = 'Subtotal:'
|
52
|
+
@total_title = 'Total:'
|
53
|
+
@repeat_headers_for_groups = false
|
54
|
+
@custom_headers = []
|
55
|
+
@row_attributes = nil
|
56
|
+
end
|
57
|
+
|
58
|
+
# Define a new column for the table
|
59
|
+
def column(id, title = '', opts = {}, &b)
|
60
|
+
@columns << DataTable::Column.new(id, title, opts, &b)
|
61
|
+
end
|
62
|
+
|
63
|
+
def prepare_data
|
64
|
+
calculate_parent_subtotals if @groupings.count > 1
|
65
|
+
group_data! if @grouped_data
|
66
|
+
calculate_subtotals! if subtotals?
|
67
|
+
calculate_totals! if totals?
|
68
|
+
end
|
69
|
+
|
70
|
+
####################
|
71
|
+
# GENERAL RENDERING
|
72
|
+
####################
|
73
|
+
def render
|
74
|
+
render_data_table
|
75
|
+
end
|
76
|
+
|
77
|
+
def render_data_table
|
78
|
+
html = "<table id='#{@id}' class='data_table #{@css_class}' cellspacing='0' cellpadding='0'>"
|
79
|
+
html << "<caption>#{@title}</caption>" if @title
|
80
|
+
html << render_data_table_header if @display_header
|
81
|
+
if @collection.any?
|
82
|
+
html << render_data_table_body(@collection)
|
83
|
+
html << render_totals if totals?
|
84
|
+
else
|
85
|
+
html << "<tr><td class='empty_data_table' colspan='#{@columns.size}'>#{@empty_text}</td></tr>"
|
86
|
+
end
|
87
|
+
html << '</table>'
|
88
|
+
end
|
89
|
+
|
90
|
+
def render_data_table_header
|
91
|
+
html = '<thead>'
|
92
|
+
|
93
|
+
html << render_custom_table_header unless @custom_headers.empty?
|
94
|
+
|
95
|
+
html << '<tr>'
|
96
|
+
@columns.each do |col|
|
97
|
+
html << col.render_column_header
|
98
|
+
end
|
99
|
+
html << '</tr></thead>'
|
100
|
+
end
|
101
|
+
|
102
|
+
def render_custom_table_header
|
103
|
+
html = "<tr class='custom-header'>"
|
104
|
+
@custom_headers.each do |h|
|
105
|
+
html << "<th class=\"#{h[:css]}\" colspan=\"#{h[:colspan]}\" style=\"#{h[:style]}\">#{h[:text]}</th>"
|
106
|
+
end
|
107
|
+
html << '</tr>'
|
108
|
+
end
|
109
|
+
|
110
|
+
def render_data_table_body(collection)
|
111
|
+
if @grouped_data
|
112
|
+
render_grouped_data_table_body(collection)
|
113
|
+
else
|
114
|
+
"<tbody>#{render_rows(collection)}</tbody>"
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def render_rows(collection)
|
119
|
+
html = ''
|
120
|
+
collection.each_with_index do |row, row_index|
|
121
|
+
css_class = @alternate_rows && row_index.odd? ? 'alt ' : ''
|
122
|
+
if @row_style && style = @row_style.call(row, row_index)
|
123
|
+
css_class << style
|
124
|
+
end
|
125
|
+
|
126
|
+
attributes = @row_attributes.nil? ? {} : @row_attributes.call(row)
|
127
|
+
html << render_row(row, row_index, css_class, attributes)
|
128
|
+
end
|
129
|
+
html
|
130
|
+
end
|
131
|
+
|
132
|
+
def render_row(row, row_index, css_class = '', row_attributes = {})
|
133
|
+
attributes = if row_attributes.nil?
|
134
|
+
''
|
135
|
+
else
|
136
|
+
row_attributes.map { |attr, val| "#{attr}='#{val}'" }.join ' '
|
137
|
+
end
|
138
|
+
|
139
|
+
html = "<tr class='row_#{row_index} #{css_class}' #{attributes}>"
|
140
|
+
@columns.each_with_index do |col, col_index|
|
141
|
+
cell = begin
|
142
|
+
row[col.name]
|
143
|
+
rescue
|
144
|
+
nil
|
145
|
+
end
|
146
|
+
html << col.render_cell(cell, row, row_index, col_index)
|
147
|
+
end
|
148
|
+
html << '</tr>'
|
149
|
+
end
|
150
|
+
|
151
|
+
# define a custom block to be used to determine the css class for a row.
|
152
|
+
def row_style(&b)
|
153
|
+
@row_style = b
|
154
|
+
end
|
155
|
+
|
156
|
+
def custom_header(&blk)
|
157
|
+
instance_eval(&blk)
|
158
|
+
end
|
159
|
+
|
160
|
+
def th(header_text, options)
|
161
|
+
@custom_headers << options.merge(text: header_text)
|
162
|
+
end
|
163
|
+
|
164
|
+
def row_attributes(&b)
|
165
|
+
@row_attributes = b
|
166
|
+
end
|
167
|
+
|
168
|
+
#############
|
169
|
+
# GROUPING
|
170
|
+
#############
|
171
|
+
|
172
|
+
# TODO: allow for group column only, block only and group column and block
|
173
|
+
def group_by(group_column, level = {level: 0}, &_blk)
|
174
|
+
if level.nil? && @groupings.count >= 1
|
175
|
+
raise 'a level designation is required when using multiple groupings.'
|
176
|
+
end
|
177
|
+
@grouped_data = true
|
178
|
+
@groupings[level ? level[:level] : 0] = group_column
|
179
|
+
@columns.reject! { |c| c.name == group_column }
|
180
|
+
end
|
181
|
+
|
182
|
+
def group_data!
|
183
|
+
@groupings.compact!
|
184
|
+
@collection = if @groupings.count > 1
|
185
|
+
collection.group_by_recursive(@groupings)
|
186
|
+
else
|
187
|
+
collection.group_by { |row| row[@groupings.first] }
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
def render_grouped_data_table_body(collection)
|
192
|
+
html = ''
|
193
|
+
collection.keys.each do |group_name|
|
194
|
+
html << render_group(group_name, collection[group_name])
|
195
|
+
end
|
196
|
+
html
|
197
|
+
end
|
198
|
+
|
199
|
+
def render_group_header(group_header, index = nil)
|
200
|
+
css_classes = ['group_header']
|
201
|
+
css_classes << ["level_#{index}"] unless index.nil?
|
202
|
+
html = "<tr class='#{css_classes.join(' ')}'>"
|
203
|
+
html << "<th colspan='#{@columns.size}'>#{group_header}</th>"
|
204
|
+
html << '</tr>'
|
205
|
+
repeat_headers(html) if @repeat_headers_for_groups
|
206
|
+
html
|
207
|
+
end
|
208
|
+
|
209
|
+
def repeat_headers(html)
|
210
|
+
html << "<tr class='col_headers'>"
|
211
|
+
@columns.each_with_index do |col, _i|
|
212
|
+
html << col.render_column_header
|
213
|
+
end
|
214
|
+
html << '</tr>'
|
215
|
+
end
|
216
|
+
|
217
|
+
def render_group(group_header, group_data)
|
218
|
+
html = "<tbody class='#{group_header.to_s.downcase.gsub(/[^A-Za-z0-9]+/, '_')}'>"
|
219
|
+
html << render_group_header(group_header, 0)
|
220
|
+
if group_data.is_a? Array
|
221
|
+
html << render_rows(group_data)
|
222
|
+
html << render_subtotals(group_header, group_data) if subtotals?
|
223
|
+
elsif group_data.is_a? Hash
|
224
|
+
html << render_group_recursive(group_data, 1, group_header)
|
225
|
+
end
|
226
|
+
html << '</tbody>'
|
227
|
+
end
|
228
|
+
|
229
|
+
def render_group_recursive(collection, index = 1, group_parent = nil, ancestors = nil)
|
230
|
+
html = ''
|
231
|
+
ancestors ||= []
|
232
|
+
collection.each_pair do |group_name, group_data|
|
233
|
+
ancestors << group_parent unless ancestors[0] == group_parent
|
234
|
+
ancestors << group_name unless ancestors.length == @groupings.length
|
235
|
+
if group_data.is_a?(Hash)
|
236
|
+
html << render_group_header(group_name, index)
|
237
|
+
html << render_group_recursive(group_data, index + 1, nil, ancestors)
|
238
|
+
elsif group_data.is_a?(Array)
|
239
|
+
html << render_group_header(group_name, index)
|
240
|
+
html << render_rows(group_data)
|
241
|
+
ancestors.pop
|
242
|
+
html << render_subtotals(group_name, group_data, ancestors) if subtotals?
|
243
|
+
end
|
244
|
+
end
|
245
|
+
html << render_parent_subtotals(ancestors) if @parent_subtotals
|
246
|
+
ancestors.pop
|
247
|
+
html
|
248
|
+
end
|
249
|
+
|
250
|
+
#############
|
251
|
+
# TOTALS AND SUBTOTALS
|
252
|
+
#############
|
253
|
+
def render_totals
|
254
|
+
html = '<tfoot>'
|
255
|
+
@total_calculations.each_with_index do |totals_row, index|
|
256
|
+
next if totals_row.nil?
|
257
|
+
|
258
|
+
html << "<tr class='total index_#{index}'>"
|
259
|
+
@columns.each do |col|
|
260
|
+
value = totals_row[col.name] ||= nil
|
261
|
+
html << col.render_cell(value)
|
262
|
+
end
|
263
|
+
html << '</tr>'
|
264
|
+
end
|
265
|
+
html << '</tfoot>'
|
266
|
+
end
|
267
|
+
|
268
|
+
def render_parent_subtotals(group_array)
|
269
|
+
html = ''
|
270
|
+
@parent_subtotals[group_array].each_with_index do |group, index|
|
271
|
+
html << "<tr class='parent_subtotal "
|
272
|
+
html << "index_#{index} #{group_array.join('_').gsub(/\s/, '_').downcase}'>"
|
273
|
+
@columns.each do |col|
|
274
|
+
value = group[col.name] ? group[col.name].values[0] : nil
|
275
|
+
html << col.render_cell(value)
|
276
|
+
end
|
277
|
+
html << '</tr>'
|
278
|
+
end
|
279
|
+
html
|
280
|
+
end
|
281
|
+
|
282
|
+
# ancestors should be an array
|
283
|
+
def render_subtotals(group_header, _group_data = nil, ancestors = nil)
|
284
|
+
html = ''
|
285
|
+
path = ancestors.nil? ? [] : ancestors.dup
|
286
|
+
path << group_header
|
287
|
+
@subtotal_calculations[path].each_with_index do |group, index|
|
288
|
+
html << "<tr class='subtotal index_#{index}'>"
|
289
|
+
@columns.each do |col|
|
290
|
+
value = group[col.name] ? group[col.name].values[0] : nil
|
291
|
+
html << col.render_cell(value)
|
292
|
+
end
|
293
|
+
html << '</tr>'
|
294
|
+
end
|
295
|
+
html
|
296
|
+
end
|
297
|
+
|
298
|
+
def subtotal(column_name, function = nil, index = 0, &block)
|
299
|
+
raise 'You must supply an index value' if @subtotals.count >= 1 && index.nil?
|
300
|
+
total_row @subtotals, column_name, function, index, &block
|
301
|
+
end
|
302
|
+
|
303
|
+
def subtotals?
|
304
|
+
!@subtotals.empty?
|
305
|
+
end
|
306
|
+
|
307
|
+
def total(column_name, function = nil, index = 0, &block)
|
308
|
+
raise 'You must supply an index value' if @totals.count >= 1 && index.nil?
|
309
|
+
total_row @totals, column_name, function, index, &block
|
310
|
+
end
|
311
|
+
|
312
|
+
def totals?
|
313
|
+
!@totals.empty?
|
314
|
+
end
|
315
|
+
|
316
|
+
# TODO: Refactor to shorten method. Also revise tests.
|
317
|
+
def calculate_totals!
|
318
|
+
@total_calculations = []
|
319
|
+
@totals.each_with_index do |row, index|
|
320
|
+
next if row.nil?
|
321
|
+
|
322
|
+
if @collection.is_a?(Hash)
|
323
|
+
collection = []
|
324
|
+
@collection.each_pair_recursive { |_k, v| collection.concat(v) }
|
325
|
+
end
|
326
|
+
collection = @collection if @collection.is_a? Array
|
327
|
+
@total_calculations[index] = {} if @total_calculations[index].nil?
|
328
|
+
row.each do |item|
|
329
|
+
@total_calculations[index][item[0]] = calculate(collection, item[0], item[1])
|
330
|
+
end
|
331
|
+
end
|
332
|
+
end
|
333
|
+
|
334
|
+
def calculate_subtotals!
|
335
|
+
raise 'Subtotals only work with grouped results sets' unless @grouped_data
|
336
|
+
@subtotal_calculations ||= Hash.new { |h, k| h[k] = [] }
|
337
|
+
@subtotals.each_with_index do |subtotal_type, index|
|
338
|
+
subtotal_type.each do |subtotal|
|
339
|
+
@collection.each_pair_with_parents(@groupings.count) do |group_name, group_data, parents|
|
340
|
+
path = parents + [group_name]
|
341
|
+
result = calculate(group_data, subtotal[0], subtotal[1], path)
|
342
|
+
@subtotal_calculations[path][index] ||= {}
|
343
|
+
@subtotal_calculations[path][index][subtotal[0]] = {subtotal[1] => result}
|
344
|
+
end
|
345
|
+
end
|
346
|
+
end
|
347
|
+
end
|
348
|
+
|
349
|
+
def calculate_parent_subtotals
|
350
|
+
@parent_subtotals = Hash.new { |h, k| h[k] = [] }
|
351
|
+
# Iterate over all the parent groups
|
352
|
+
parent_groups = @groupings.slice(0, @groupings.count - 1).compact
|
353
|
+
parent_groups.count.times do
|
354
|
+
# Group by each parent on the fly
|
355
|
+
@subtotals.each_with_index do |subtotal, index|
|
356
|
+
@collection.group_by_recursive(parent_groups).each_pair_with_parents do |group_name, data, parents|
|
357
|
+
subtotal.each do |s|
|
358
|
+
path = parents + [group_name]
|
359
|
+
result = calculate(data, s[0], s[1], path)
|
360
|
+
@parent_subtotals[path][index] ||= {} if @parent_subtotals[path][index].nil?
|
361
|
+
@parent_subtotals[path][index][s[0]] = {s[1] => result}
|
362
|
+
end
|
363
|
+
end
|
364
|
+
end
|
365
|
+
parent_groups.pop
|
366
|
+
end
|
367
|
+
end
|
368
|
+
|
369
|
+
# TODO: Write test for this
|
370
|
+
def calculate(data, column_name, function, path = nil)
|
371
|
+
column = @columns.select { |col| col.name == column_name }
|
372
|
+
if function.is_a?(Proc)
|
373
|
+
calculate_with_proc(function, data, column, path)
|
374
|
+
elsif function.is_a?(Array) && function[1].is_a?(Proc)
|
375
|
+
calculate_array_and_proc(function, data, column_name, path)
|
376
|
+
elsif function.is_a?(Array)
|
377
|
+
calculate_many(function, data, column_name, path)
|
378
|
+
else
|
379
|
+
send("calculate_#{function}", data, column_name)
|
380
|
+
end
|
381
|
+
end
|
382
|
+
|
383
|
+
def calculate_with_proc(function, data, column = nil, path = nil)
|
384
|
+
case function.arity
|
385
|
+
when 1 then function.call(data)
|
386
|
+
when 2 then function.call(data, column.first)
|
387
|
+
when 3 then function.call(data, column.first, path.last)
|
388
|
+
end
|
389
|
+
end
|
390
|
+
|
391
|
+
def calculate_array_and_proc(function, data, column = nil, path = nil)
|
392
|
+
result = send("calculate_#{function[0]}", data, column)
|
393
|
+
case function[1].arity
|
394
|
+
when 1 then function[1].call(result)
|
395
|
+
when 2 then function[1].call(result, column.first)
|
396
|
+
when 3 then function[1].call(result, column.first, path.last)
|
397
|
+
end
|
398
|
+
end
|
399
|
+
|
400
|
+
def calculate_many(function, data, column_name, _path = nil)
|
401
|
+
function.each do |func|
|
402
|
+
if func.is_a? Array
|
403
|
+
send("calculate_#{func[0]}", data, column_name)
|
404
|
+
else
|
405
|
+
send("calculate_#{func}", data, column_name)
|
406
|
+
end
|
407
|
+
end
|
408
|
+
end
|
409
|
+
|
410
|
+
def calculate_sum(collection, column_name)
|
411
|
+
collection.inject(0) { |sum, row| sum + row[column_name].to_f }
|
412
|
+
end
|
413
|
+
|
414
|
+
def calculate_avg(collection, column_name)
|
415
|
+
sum = calculate_sum(collection, column_name)
|
416
|
+
sum / collection.size
|
417
|
+
end
|
418
|
+
|
419
|
+
def calculate_max(collection, column_name)
|
420
|
+
collection.collect { |r| r[column_name].to_f }.max
|
421
|
+
end
|
422
|
+
|
423
|
+
def calculate_min(collection, column_name)
|
424
|
+
collection.collect { |r| r[column_name].to_f }.min
|
425
|
+
end
|
426
|
+
|
427
|
+
private
|
428
|
+
|
429
|
+
# Define a new total column definition.
|
430
|
+
# total columns take the name of the column that should be totaled
|
431
|
+
# they also take a default aggregate function name and/or a block
|
432
|
+
# if only a default function is given, then it is used to calculate the total
|
433
|
+
# if only a block is given then only it is used to calculated the total
|
434
|
+
# if both a block and a function are given then the default aggregate function is called first
|
435
|
+
# then its result is passed into the block for further processing.
|
436
|
+
def total_row(collection, column_name, function = nil, index = nil, &block)
|
437
|
+
function_or_block = function || block
|
438
|
+
f = function && block_given? ? [function, block] : function_or_block
|
439
|
+
collection[index] = {} if collection[index].nil?
|
440
|
+
collection[index][column_name] = f
|
441
|
+
end
|
442
|
+
end
|
443
|
+
end
|