data-table 1.0.0 → 2.0.3

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.
@@ -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.first td,
36
+ .data_table .parent_subtotal.first 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,458 @@
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
+ next if group.nil?
272
+
273
+ html << "<tr class='parent_subtotal "
274
+ html << "index_#{index} #{group_array.join('_').gsub(/\s/, '_').downcase}'>"
275
+ @columns.each do |col|
276
+ value = group[col.name] ? group[col.name].values[0] : nil
277
+ html << col.render_cell(value)
278
+ end
279
+ html << '</tr>'
280
+ end
281
+ html
282
+ end
283
+
284
+ # ancestors should be an array
285
+ def render_subtotals(group_header, _group_data = nil, ancestors = nil)
286
+ html = ''
287
+ path = ancestors.nil? ? [] : ancestors.dup
288
+ path << group_header
289
+
290
+ is_first_subtotal = true
291
+
292
+ @subtotal_calculations[path].each_with_index do |group, index|
293
+ next if group.empty?
294
+
295
+ html << "<tr class='subtotal index_#{index} #{'first' if is_first_subtotal}'>"
296
+ @columns.each do |col|
297
+ value = group[col.name] ? group[col.name].values[0] : nil
298
+ html << col.render_cell(value)
299
+ end
300
+ html << '</tr>'
301
+
302
+ is_first_subtotal = false
303
+ end
304
+ html
305
+ end
306
+
307
+ def subtotal(column_name, function = nil, index = 0, &block)
308
+ raise 'You must supply an index value' if @subtotals.count >= 1 && index.nil?
309
+ total_row @subtotals, column_name, function, index, &block
310
+ end
311
+
312
+ def subtotals?
313
+ !@subtotals.empty?
314
+ end
315
+
316
+ def total(column_name, function = nil, index = 0, &block)
317
+ raise 'You must supply an index value' if @totals.count >= 1 && index.nil?
318
+ total_row @totals, column_name, function, index, &block
319
+ end
320
+
321
+ def totals?
322
+ !@totals.empty?
323
+ end
324
+
325
+ # TODO: Refactor to shorten method. Also revise tests.
326
+ def calculate_totals!
327
+ @total_calculations = []
328
+ @totals.each_with_index do |row, index|
329
+ next if row.nil?
330
+
331
+ if @collection.is_a?(Hash)
332
+ collection = []
333
+ @collection.each_pair_recursive { |_k, v| collection.concat(v) }
334
+ end
335
+ collection = @collection if @collection.is_a? Array
336
+ @total_calculations[index] = {} if @total_calculations[index].nil?
337
+ row.each do |item|
338
+ @total_calculations[index][item[0]] = calculate(collection, item[0], item[1])
339
+ end
340
+ end
341
+ end
342
+
343
+ def calculate_subtotals!
344
+ raise 'Subtotals only work with grouped results sets' unless @grouped_data
345
+ @subtotal_calculations ||= Hash.new { |h, k| h[k] = [] }
346
+ @subtotals.each_with_index do |subtotal_type, index|
347
+ subtotal_type.each do |subtotal|
348
+ @collection.each_pair_with_parents(@groupings.count) do |group_name, group_data, parents|
349
+ path = parents + [group_name]
350
+ result = calculate(group_data, subtotal[0], subtotal[1], path)
351
+ (0..index).each do |index|
352
+ @subtotal_calculations[path][index] ||= {}
353
+ end
354
+ @subtotal_calculations[path][index][subtotal[0]] = {subtotal[1] => result}
355
+ end
356
+ end
357
+ end
358
+ end
359
+
360
+ def calculate_parent_subtotals
361
+ @parent_subtotals = Hash.new { |h, k| h[k] = [] }
362
+ # Iterate over all the parent groups
363
+ parent_groups = @groupings.slice(0, @groupings.count - 1).compact
364
+ parent_groups.count.times do
365
+ # Group by each parent on the fly
366
+ @subtotals.each_with_index do |subtotal, index|
367
+ @collection.group_by_recursive(parent_groups).each_pair_with_parents do |group_name, data, parents|
368
+ subtotal.each do |s|
369
+ path = parents + [group_name]
370
+ result = calculate(data, s[0], s[1], path)
371
+ @parent_subtotals[path][index] ||= {} if @parent_subtotals[path][index].nil?
372
+ @parent_subtotals[path][index][s[0]] = {s[1] => result}
373
+ end
374
+ end
375
+ end
376
+ parent_groups.pop
377
+ end
378
+ end
379
+
380
+ # TODO: Write test for this
381
+ def calculate(data, column_name, function, path = nil)
382
+ column = @columns.select { |col| col.name == column_name }
383
+ if function.is_a?(Proc)
384
+ calculate_with_proc(function, data, column, path)
385
+ elsif function.is_a?(Array) && function[1].is_a?(Proc)
386
+ calculate_array_and_proc(function, data, column_name, path)
387
+ elsif function.is_a?(Array)
388
+ calculate_many(function, data, column_name, path)
389
+ else
390
+ send("calculate_#{function}", data, column_name)
391
+ end
392
+ end
393
+
394
+ def calculate_with_proc(function, data, column = nil, path = nil)
395
+ case function.arity
396
+ when 1 then function.call(data)
397
+ when 2 then function.call(data, column.first)
398
+ when 3 then function.call(data, column.first, path.last)
399
+ end
400
+ end
401
+
402
+ def calculate_array_and_proc(function, data, column = nil, path = nil)
403
+ result = send("calculate_#{function[0]}", data, column)
404
+ case function[1].arity
405
+ when 1 then function[1].call(result)
406
+ when 2 then function[1].call(result, column.first)
407
+ when 3 then function[1].call(result, column.first, path.last)
408
+ end
409
+ end
410
+
411
+ def calculate_many(function, data, column_name, _path = nil)
412
+ function.each do |func|
413
+ if func.is_a? Array
414
+ send("calculate_#{func[0]}", data, column_name)
415
+ else
416
+ send("calculate_#{func}", data, column_name)
417
+ end
418
+ end
419
+ end
420
+
421
+ def calculate_sum(collection, column_name)
422
+ collection.inject(0) { |sum, row| sum + row[column_name].to_f }
423
+ end
424
+
425
+ def calculate_avg(collection, column_name)
426
+ return 0 if collection.empty?
427
+
428
+ sum = calculate_sum(collection, column_name)
429
+ sum / collection.size
430
+ end
431
+
432
+ def calculate_max(collection, column_name)
433
+ collection.collect { |r| r[column_name].to_f }.max
434
+ end
435
+
436
+ def calculate_min(collection, column_name)
437
+ collection.collect { |r| r[column_name].to_f }.min
438
+ end
439
+
440
+ private
441
+
442
+ # Define a new total column definition.
443
+ # total columns take the name of the column that should be totaled
444
+ # they also take a default aggregate function name and/or a block
445
+ # if only a default function is given, then it is used to calculate the total
446
+ # if only a block is given then only it is used to calculated the total
447
+ # if both a block and a function are given then the default aggregate function is called first
448
+ # then its result is passed into the block for further processing.
449
+ def total_row(collection, column_name, function = nil, index = nil, &block)
450
+ function_or_block = function || block
451
+ f = function && block_given? ? [function, block] : function_or_block
452
+ (0..index).each do |index|
453
+ collection[index] = {} if collection[index].nil?
454
+ end
455
+ collection[index][column_name] = f
456
+ end
457
+ end
458
+ end