data-table 1.0.1 → 2.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 +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
|