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.
@@ -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