data-table 1.0.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.
- data/.gitignore +4 -0
- data/Gemfile +4 -0
- data/Rakefile +1 -0
- data/data-table.gemspec +20 -0
- data/lib/data-table.rb +3 -0
- data/lib/data-table/data_table.rb +385 -0
- data/lib/data-table/data_table_column.rb +60 -0
- data/lib/data-table/version.rb +3 -0
- metadata +55 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
data/data-table.gemspec
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "data-table/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "data-table"
|
7
|
+
s.version = DataTable::VERSION
|
8
|
+
s.authors = ["Steve Erickson", "Jeff Fraser"]
|
9
|
+
s.email = ["sixfeetover@gmail.com"]
|
10
|
+
s.homepage = "https://github.com/sixfeetover/data-table"
|
11
|
+
s.summary = %q{Turn arrays of hashes or models in to an HTML table.}
|
12
|
+
s.description = %q{data-table is a simple gem that provides a DSL for allowing you do turn an array of hashes or ActiveRecord objects into an HTML table.}
|
13
|
+
|
14
|
+
s.rubyforge_project = "data-table"
|
15
|
+
|
16
|
+
s.files = `git ls-files`.split("\n")
|
17
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
18
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
19
|
+
s.require_paths = ["lib"]
|
20
|
+
end
|
data/lib/data-table.rb
ADDED
@@ -0,0 +1,385 @@
|
|
1
|
+
##
|
2
|
+
# Config Options
|
3
|
+
#
|
4
|
+
# id: the html id
|
5
|
+
# title: the title of the data table
|
6
|
+
# subtitle: the subtitle of the data table
|
7
|
+
# css_class: an extra css class to get applied to the table
|
8
|
+
# empty_text: the text to display of the collection is empty
|
9
|
+
# display_header => false: hide the column headers for the data table
|
10
|
+
# alternate_rows => false: turn off alternating of row css classes
|
11
|
+
# alternate_cols => true: turn on alternating of column classes, defaults to false
|
12
|
+
#
|
13
|
+
# columns: an array of hashes of the column specs for this table
|
14
|
+
#
|
15
|
+
# group_by: an array of columns to group on
|
16
|
+
# pivot_on: an array of columns to pivot 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
|
+
|
23
|
+
class DataTable
|
24
|
+
|
25
|
+
#############
|
26
|
+
# CONFIG
|
27
|
+
#############
|
28
|
+
attr_reader :grouped_data, :pivoted_data, :subtotals, :totals, :subtotal_calculations, :total_calculations
|
29
|
+
attr_accessor :id, :title, :css_class, :empty_text, :alternate_rows, :alternate_cols, :display_header, :hide_if_empty, :repeat_headers_for_groups, :custom_headers
|
30
|
+
|
31
|
+
def initialize(collection)
|
32
|
+
@collection = collection
|
33
|
+
default_options!
|
34
|
+
|
35
|
+
@columns = []
|
36
|
+
|
37
|
+
@groupings, @pivot_columns = [], []
|
38
|
+
@pivoted_data, @grouped_data = false, false
|
39
|
+
@subtotals, @totals = {}, {}
|
40
|
+
end
|
41
|
+
|
42
|
+
def default_options!
|
43
|
+
@id = ''
|
44
|
+
@title = ''
|
45
|
+
@subtitle = ''
|
46
|
+
@css_class = ''
|
47
|
+
@empty_text = 'No records found'
|
48
|
+
@hide_if_empty = false
|
49
|
+
@display_header = true
|
50
|
+
@alternate_rows = true
|
51
|
+
@alternate_cols = false
|
52
|
+
@subtotal_title = "Subtotal:"
|
53
|
+
@total_title = "Total:"
|
54
|
+
@repeat_headers_for_groups = false
|
55
|
+
@custom_headers = []
|
56
|
+
@row_attributes = nil
|
57
|
+
end
|
58
|
+
|
59
|
+
def self.default_css_styles
|
60
|
+
<<-CSS_STYLE
|
61
|
+
.data_table {width: 100%; empty-cells: show}
|
62
|
+
.data_table td, .data_table th {padding: 3px}
|
63
|
+
|
64
|
+
.data_table caption {font-size: 2em; font-weight: bold}
|
65
|
+
|
66
|
+
.data_table thead {}
|
67
|
+
.data_table thead th {background-color: #ddd; border-bottom: 1px solid #bbb;}
|
68
|
+
|
69
|
+
.data_table tbody {}
|
70
|
+
.data_table tbody tr.alt {background-color: #eee;}
|
71
|
+
|
72
|
+
.data_table .group_header th {text-align: left;}
|
73
|
+
|
74
|
+
.data_table .subtotal {}
|
75
|
+
.data_table .subtotal td {border-top: 1px solid #000;}
|
76
|
+
|
77
|
+
.data_table tfoot {}
|
78
|
+
.data_table tfoot td {border-top: 1px solid #000;}
|
79
|
+
|
80
|
+
.empty_data_table {text-align: center; background-color: #ffc;}
|
81
|
+
|
82
|
+
/* Data Types */
|
83
|
+
.data_table .number, .data_table .money {text-align: right}
|
84
|
+
.data_table .text {text-align: left}
|
85
|
+
CSS_STYLE
|
86
|
+
end
|
87
|
+
|
88
|
+
# Define a new column for the table
|
89
|
+
def column(id, title="", opts={}, &b)
|
90
|
+
@columns << DataTableColumn.new(id, title, opts, &b)
|
91
|
+
end
|
92
|
+
|
93
|
+
def prepare_data
|
94
|
+
self.pivot_data! if @pivoted_data
|
95
|
+
self.group_data! if @grouped_data
|
96
|
+
|
97
|
+
self.calculate_subtotals! if has_subtotals?
|
98
|
+
self.calculate_totals! if has_totals?
|
99
|
+
end
|
100
|
+
|
101
|
+
#############
|
102
|
+
# GENERAL RENDERING
|
103
|
+
#############
|
104
|
+
|
105
|
+
def self.render(collection, &blk)
|
106
|
+
# make a new table
|
107
|
+
t = self.new(collection)
|
108
|
+
|
109
|
+
# yield it to the block for configuration
|
110
|
+
yield t
|
111
|
+
|
112
|
+
# modify the data structure if necessary and do calculations
|
113
|
+
t.prepare_data
|
114
|
+
|
115
|
+
# render the table
|
116
|
+
t.render.html_safe
|
117
|
+
end
|
118
|
+
|
119
|
+
def render
|
120
|
+
render_data_table
|
121
|
+
end
|
122
|
+
|
123
|
+
def render_data_table
|
124
|
+
html = "<table id='#{@id}' class='data_table #{@css_class}' cellspacing='0' cellpadding='0'>"
|
125
|
+
html << "<caption>#{@title}</caption>" if @title
|
126
|
+
html << render_data_table_header if @display_header
|
127
|
+
if @collection.any?
|
128
|
+
html << render_data_table_body(@collection)
|
129
|
+
html << render_totals if has_totals?
|
130
|
+
else
|
131
|
+
html << "<tr><td class='empty_data_table' colspan='#{@columns.size}'>#{@empty_text}</td></tr>"
|
132
|
+
end
|
133
|
+
html << "</table>"
|
134
|
+
end
|
135
|
+
|
136
|
+
def render_data_table_header
|
137
|
+
html = "<thead>"
|
138
|
+
|
139
|
+
html << render_custom_table_header unless @custom_headers.empty?
|
140
|
+
|
141
|
+
html << "<tr>"
|
142
|
+
@columns.each do |col|
|
143
|
+
html << col.render_column_header
|
144
|
+
end
|
145
|
+
html << "</tr></thead>"
|
146
|
+
end
|
147
|
+
|
148
|
+
def render_custom_table_header
|
149
|
+
html = "<tr>"
|
150
|
+
@custom_headers.each do |h|
|
151
|
+
html << "<th class=\"#{h[:css]}\" colspan=\"#{h[:colspan]}\">#{h[:text]}</th>"
|
152
|
+
end
|
153
|
+
html << "</tr>"
|
154
|
+
end
|
155
|
+
|
156
|
+
def render_data_table_body(collection)
|
157
|
+
if @grouped_data
|
158
|
+
render_grouped_data_table_body(collection)
|
159
|
+
else
|
160
|
+
"<tbody>#{render_rows(collection)}</tbody>"
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def render_rows(collection)
|
165
|
+
html = ""
|
166
|
+
collection.each_with_index do |row, row_index|
|
167
|
+
css_class = @alternate_rows && row_index % 2 == 1 ? 'alt ' : ''
|
168
|
+
if @row_style && style = @row_style.call(row, row_index)
|
169
|
+
css_class << style
|
170
|
+
end
|
171
|
+
|
172
|
+
attributes = @row_attributes.nil? ? {} : @row_attributes.call(row)
|
173
|
+
html << render_row(row, row_index, css_class, attributes)
|
174
|
+
end
|
175
|
+
html
|
176
|
+
end
|
177
|
+
|
178
|
+
def render_row(row, row_index, css_class='', row_attributes={})
|
179
|
+
if row_attributes.nil?
|
180
|
+
attributes = ''
|
181
|
+
else
|
182
|
+
attributes = row_attributes.map {|attr, val| "#{attr}='#{val}'"}.join " "
|
183
|
+
end
|
184
|
+
|
185
|
+
html = "<tr class='row_#{row_index} #{css_class}' #{attributes}>"
|
186
|
+
@columns.each_with_index do |col, col_index|
|
187
|
+
cell = row[col.name] rescue nil
|
188
|
+
html << col.render_cell(cell, row, row_index, col_index)
|
189
|
+
end
|
190
|
+
html << "</tr>"
|
191
|
+
end
|
192
|
+
|
193
|
+
# define a custom block to be used to determine the css class for a row.
|
194
|
+
def row_style(&b)
|
195
|
+
@row_style = b
|
196
|
+
end
|
197
|
+
|
198
|
+
def custom_header(&blk)
|
199
|
+
instance_eval(&blk)
|
200
|
+
end
|
201
|
+
|
202
|
+
def th(header_text, options)
|
203
|
+
@custom_headers << options.merge(:text => header_text)
|
204
|
+
end
|
205
|
+
|
206
|
+
def row_attributes(&b)
|
207
|
+
@row_attributes = b
|
208
|
+
end
|
209
|
+
|
210
|
+
#############
|
211
|
+
# GROUPING
|
212
|
+
#############
|
213
|
+
|
214
|
+
# TODO: allow for group column only, block only and group column and block
|
215
|
+
def group_by(group_column, &blk)
|
216
|
+
@grouped_data = true
|
217
|
+
@groupings = group_column
|
218
|
+
@columns.reject!{|c| c.name == group_column}
|
219
|
+
end
|
220
|
+
|
221
|
+
def group_data!
|
222
|
+
@collection = @collection.group_by {|row| row[@groupings] }
|
223
|
+
end
|
224
|
+
|
225
|
+
def render_grouped_data_table_body(collection)
|
226
|
+
html = ""
|
227
|
+
collection.keys.each do |group_name|
|
228
|
+
html << render_group(group_name, collection[group_name])
|
229
|
+
end
|
230
|
+
html
|
231
|
+
end
|
232
|
+
|
233
|
+
def render_group_header(group_header)
|
234
|
+
html = "<tr class='group_header'>"
|
235
|
+
if @repeat_headers_for_groups
|
236
|
+
@columns.each_with_index do |col, i|
|
237
|
+
html << (i == 0 ? "<th>#{group_header}</th>" : col.render_column_header)
|
238
|
+
end
|
239
|
+
else
|
240
|
+
html << "<th colspan='#{@columns.size}'>#{group_header}</th>"
|
241
|
+
end
|
242
|
+
html << "</tr>"
|
243
|
+
html
|
244
|
+
end
|
245
|
+
|
246
|
+
def render_group(group_header, group_data)
|
247
|
+
html = "<tbody class='#{group_header.to_s.downcase.gsub(/[^A-Za-z0-9]+/, '_')}'>" #replace non-letters and numbers with '_'
|
248
|
+
html << render_group_header(group_header)
|
249
|
+
html << render_rows(group_data)
|
250
|
+
html << render_subtotals(group_header, group_data) if has_subtotals?
|
251
|
+
html << "</tbody>"
|
252
|
+
end
|
253
|
+
|
254
|
+
|
255
|
+
#############
|
256
|
+
# PIVOTING
|
257
|
+
#############
|
258
|
+
|
259
|
+
def pivot_on(pivot_column)
|
260
|
+
@pivoted_data = true
|
261
|
+
@pivot_column = pivot_column
|
262
|
+
end
|
263
|
+
|
264
|
+
def pivot_data!
|
265
|
+
@collection.pivot_on
|
266
|
+
end
|
267
|
+
|
268
|
+
#############
|
269
|
+
# TOTALS AND SUBTOTALS
|
270
|
+
#############
|
271
|
+
def render_totals
|
272
|
+
html = "<tfoot><tr>"
|
273
|
+
@columns.each do |col|
|
274
|
+
html << col.render_cell(@total_calculations[col.name])
|
275
|
+
end
|
276
|
+
html << "</tr></tfoot>"
|
277
|
+
end
|
278
|
+
|
279
|
+
def render_subtotals(group_header, group_data)
|
280
|
+
html = "<tr class='subtotal'>"
|
281
|
+
@columns.each do |col|
|
282
|
+
html << col.render_cell(@subtotal_calculations[group_header][col.name])
|
283
|
+
end
|
284
|
+
html << "</tr>"
|
285
|
+
end
|
286
|
+
|
287
|
+
# define a new total column definition.
|
288
|
+
# total columns take the name of the column that should be totaled
|
289
|
+
# they also take a default aggregate function name and/or a block
|
290
|
+
# if only a default function is given, then it is used to calculate the total
|
291
|
+
# if only a block is given then only it is used to calculated the total
|
292
|
+
# if both a block and a function are given then the default aggregate function is called first
|
293
|
+
# then its result is passed into the block for further processing.
|
294
|
+
def subtotal(column_name, function=nil, &b)
|
295
|
+
function_or_block = function || b
|
296
|
+
f = function && block_given? ? [function, b] : function_or_block
|
297
|
+
@subtotals << {column_name => f}
|
298
|
+
end
|
299
|
+
|
300
|
+
def has_subtotals?
|
301
|
+
!@subtotals.empty?
|
302
|
+
end
|
303
|
+
|
304
|
+
# define a new total column definition.
|
305
|
+
# total columns take the name of the column that should be totaled
|
306
|
+
# they also take a default aggregate function name and/or a block
|
307
|
+
# if only a default function is given, then it is used to calculate the total
|
308
|
+
# if only a block is given then only it is used to calculated the total
|
309
|
+
# if both a block and a function are given then the default aggregate function is called first
|
310
|
+
# then its result is passed into the block for further processing.
|
311
|
+
def total(column_name, function=nil, &b)
|
312
|
+
function_or_block = function || b
|
313
|
+
f = function && block_given? ? [function, b] : function_or_block
|
314
|
+
@totals << {column_name => f}
|
315
|
+
end
|
316
|
+
|
317
|
+
def has_totals?
|
318
|
+
!@totals.empty?
|
319
|
+
end
|
320
|
+
|
321
|
+
def calculate_totals!
|
322
|
+
@total_calculations = {}
|
323
|
+
|
324
|
+
@totals.each do |column_name, function|
|
325
|
+
collection = @collection.is_a?(Hash) ? @collection.values.flatten : @collection
|
326
|
+
result = calculate(collection, column_name, function)
|
327
|
+
@total_calculations[column_name] = result
|
328
|
+
end
|
329
|
+
end
|
330
|
+
|
331
|
+
def calculate_subtotals!
|
332
|
+
@subtotal_calculations = Hash.new { |h,k| h[k] = {} }
|
333
|
+
|
334
|
+
#ensure that we are dealing with a grouped results set.
|
335
|
+
unless @grouped_data
|
336
|
+
raise 'Subtotals only work with grouped results sets'
|
337
|
+
end
|
338
|
+
|
339
|
+
@collection.each do |group_name, group_data|
|
340
|
+
@subtotals.each do |column_name, function|
|
341
|
+
result = calculate(group_data, column_name, function)
|
342
|
+
@subtotal_calculations[group_name][column_name] = result
|
343
|
+
end
|
344
|
+
end
|
345
|
+
|
346
|
+
end
|
347
|
+
|
348
|
+
def calculate(data, column_name, function)
|
349
|
+
|
350
|
+
col = @columns.select { |column| column.name == column_name }
|
351
|
+
|
352
|
+
if function.is_a?(Proc)
|
353
|
+
case function.arity
|
354
|
+
when 1; function.call(data)
|
355
|
+
when 2; function.call(data, col.first)
|
356
|
+
end
|
357
|
+
elsif function.is_a?(Array)
|
358
|
+
result = self.send("calculate_#{function[0].to_s}", data, column_name)
|
359
|
+
case function[1].arity
|
360
|
+
when 1; function[1].call(result)
|
361
|
+
when 2; function[1].call(result, col.first)
|
362
|
+
end
|
363
|
+
else
|
364
|
+
self.send("calculate_#{function.to_s}", data, column_name)
|
365
|
+
end
|
366
|
+
end
|
367
|
+
|
368
|
+
def calculate_sum(collection, column_name)
|
369
|
+
collection.inject(0) {|sum, row| sum += row[column_name].to_f }
|
370
|
+
end
|
371
|
+
|
372
|
+
def calculate_avg(collection, column_name)
|
373
|
+
sum = calculate_sum(collection, column_name)
|
374
|
+
sum / collection.size
|
375
|
+
end
|
376
|
+
|
377
|
+
def calculate_max(collection, column_name)
|
378
|
+
collection.collect{|r| r[column_name].to_f }.max
|
379
|
+
end
|
380
|
+
|
381
|
+
def calculate_min(collection, column_name)
|
382
|
+
collection.collect{|r| r[column_name].to_f }.min
|
383
|
+
end
|
384
|
+
|
385
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
class DataTableColumn
|
2
|
+
|
3
|
+
attr_reader :name
|
4
|
+
attr_accessor :display, :index, :options, :css_class, :attributes
|
5
|
+
|
6
|
+
def initialize(name, description="", opts={}, &renderer)
|
7
|
+
@name, @description, = name, description
|
8
|
+
@data_type = opts[:data_type] || :text
|
9
|
+
@help_text = opts[:help_text] || ""
|
10
|
+
@css_class = opts[:css_class]
|
11
|
+
@attributes = opts[:attributes] || {}
|
12
|
+
@width = opts[:width]
|
13
|
+
@options = opts
|
14
|
+
|
15
|
+
@display = true
|
16
|
+
@index = 0
|
17
|
+
@renderer = renderer
|
18
|
+
end
|
19
|
+
|
20
|
+
def render_cell(cell_data, row=nil, row_index=0, col_index=0)
|
21
|
+
html = ""
|
22
|
+
if @renderer && row
|
23
|
+
cell_data = cell_data.to_s
|
24
|
+
html << case @renderer.arity
|
25
|
+
when 1; @renderer.call(cell_data).to_s
|
26
|
+
when 2; @renderer.call(cell_data, row).to_s
|
27
|
+
when 3; @renderer.call(cell_data, row, row_index).to_s
|
28
|
+
when 4; @renderer.call(cell_data, row, row_index, self).to_s
|
29
|
+
when 5; @renderer.call(cell_data, row, row_index, self, col_index).to_s
|
30
|
+
end
|
31
|
+
else
|
32
|
+
html << cell_data.to_s
|
33
|
+
end
|
34
|
+
|
35
|
+
html << "</td>"
|
36
|
+
# Doing this here b/c you can't change @css_class if this is done before the renderer is called
|
37
|
+
html = "<td class='#{css_class_names}' #{custom_attributes}>" + html
|
38
|
+
end
|
39
|
+
|
40
|
+
def render_column_header
|
41
|
+
header = "<th class='#{css_class_names}' #{custom_attributes}"
|
42
|
+
header << "title='#{@help_text}' " if @help_text
|
43
|
+
header << "style='width: #{@width}'" if @width
|
44
|
+
header << ">#{@description}</th>"
|
45
|
+
header
|
46
|
+
end
|
47
|
+
|
48
|
+
def custom_attributes
|
49
|
+
@attributes.map{|k, v| "#{k}='#{v}'"}.join ' '
|
50
|
+
end
|
51
|
+
|
52
|
+
def css_class_names
|
53
|
+
class_names = []
|
54
|
+
class_names << @name.to_s
|
55
|
+
class_names << @data_type.to_s
|
56
|
+
class_names << @css_class
|
57
|
+
class_names.compact.join(' ')
|
58
|
+
end
|
59
|
+
|
60
|
+
end
|
metadata
ADDED
@@ -0,0 +1,55 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: data-table
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Steve Erickson
|
9
|
+
- Jeff Fraser
|
10
|
+
autorequire:
|
11
|
+
bindir: bin
|
12
|
+
cert_chain: []
|
13
|
+
date: 2011-09-09 00:00:00.000000000Z
|
14
|
+
dependencies: []
|
15
|
+
description: data-table is a simple gem that provides a DSL for allowing you do turn
|
16
|
+
an array of hashes or ActiveRecord objects into an HTML table.
|
17
|
+
email:
|
18
|
+
- sixfeetover@gmail.com
|
19
|
+
executables: []
|
20
|
+
extensions: []
|
21
|
+
extra_rdoc_files: []
|
22
|
+
files:
|
23
|
+
- .gitignore
|
24
|
+
- Gemfile
|
25
|
+
- Rakefile
|
26
|
+
- data-table.gemspec
|
27
|
+
- lib/data-table.rb
|
28
|
+
- lib/data-table/data_table.rb
|
29
|
+
- lib/data-table/data_table_column.rb
|
30
|
+
- lib/data-table/version.rb
|
31
|
+
homepage: https://github.com/sixfeetover/data-table
|
32
|
+
licenses: []
|
33
|
+
post_install_message:
|
34
|
+
rdoc_options: []
|
35
|
+
require_paths:
|
36
|
+
- lib
|
37
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
38
|
+
none: false
|
39
|
+
requirements:
|
40
|
+
- - ! '>='
|
41
|
+
- !ruby/object:Gem::Version
|
42
|
+
version: '0'
|
43
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
44
|
+
none: false
|
45
|
+
requirements:
|
46
|
+
- - ! '>='
|
47
|
+
- !ruby/object:Gem::Version
|
48
|
+
version: '0'
|
49
|
+
requirements: []
|
50
|
+
rubyforge_project: data-table
|
51
|
+
rubygems_version: 1.8.8
|
52
|
+
signing_key:
|
53
|
+
specification_version: 3
|
54
|
+
summary: Turn arrays of hashes or models in to an HTML table.
|
55
|
+
test_files: []
|