data-table 1.0.1 → 2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,3 +1,62 @@
1
- require "data-table/version"
2
- require "data-table/data_table"
3
- require "data-table/data_table_column"
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