lib/data-table/table.rb
module DataTable
##
# Config Options
#
# id: the html id
# title: the title of the data table
# subtitle: the subtitle of the data table
# css_class: an extra css class to get applied to the table
# empty_text: the text to display of the collection is empty
# display_header => false: hide the column headers for the data table
# alternate_rows => false: turn off alternating of row css classes
# alternate_cols => true: turn on alternating of column classes, defaults to false
#
# columns: an array of hashes of the column specs for this table
#
# group_by: an array of columns to group on
#
# subtotals: an array of hashes that contain the subtotal information for each column that should be subtotaled
# totals: an array of hashes that contain the total information for each column that should be totaled
#
##
class Table
attr_reader :collection, :grouped_data, :subtotals, :totals,
:subtotal_calculations, :total_calculations, :columns
attr_accessor :id, :title, :css_class, :empty_text,
:alternate_rows, :alternate_cols, :display_header, :hide_if_empty,
:repeat_headers_for_groups, :custom_headers
def initialize(collection)
@collection = collection
@grouped_collection = nil
default_options!
@columns = []
@groupings = []
@grouped_data = false
@subtotals = []
@totals = []
end
def default_options!
@id = ''
@title = ''
@subtitle = ''
@css_class = ''
@empty_text = 'No records found'
@hide_if_empty = false
@display_header = true
@alternate_rows = true
@alternate_cols = false
@subtotal_title = 'Subtotal:'
@total_title = 'Total:'
@repeat_headers_for_groups = false
@custom_headers = []
@row_attributes = nil
end
# Define a new column for the table
def column(id, title = '', opts = {}, &b)
@columns << DataTable::Column.new(id, title, opts, &b)
end
def prepare_data
calculate_parent_subtotals if @groupings.count > 1
group_data! if @grouped_data
calculate_subtotals! if subtotals?
calculate_totals! if totals?
end
####################
# GENERAL RENDERING
####################
def render
render_data_table
end
def render_data_table
html = "<table id='#{@id}' class='data_table #{@css_class}' cellspacing='0' cellpadding='0'>"
html << "<caption>#{@title}</caption>" if @title
html << render_data_table_header if @display_header
if @collection.any?
html << render_data_table_body(@collection)
html << render_totals if totals?
else
html << "<tr><td class='empty_data_table' colspan='#{@columns.size}'>#{@empty_text}</td></tr>"
end
html << '</table>'
end
def render_data_table_header
html = '<thead>'
html << render_custom_table_header unless @custom_headers.empty?
html << '<tr>'
@columns.each do |col|
html << col.render_column_header
end
html << '</tr></thead>'
end
def render_custom_table_header
html = "<tr class='custom-header'>"
@custom_headers.each do |h|
html << "<th class=\"#{h[:css]}\" colspan=\"#{h[:colspan]}\" style=\"#{h[:style]}\">#{h[:text]}</th>"
end
html << '</tr>'
end
def render_data_table_body(collection)
if @grouped_data
render_grouped_data_table_body(collection)
else
"<tbody>#{render_rows(collection)}</tbody>"
end
end
def render_rows(collection)
html = ''
collection.each_with_index do |row, row_index|
css_class = @alternate_rows && row_index.odd? ? 'alt ' : ''
if @row_style && style = @row_style.call(row, row_index)
css_class << style
end
attributes = @row_attributes.nil? ? {} : @row_attributes.call(row)
html << render_row(row, row_index, css_class, attributes)
end
html
end
def render_row(row, row_index, css_class = '', row_attributes = {})
attributes = if row_attributes.nil?
''
else
row_attributes.map { |attr, val| "#{attr}='#{val}'" }.join ' '
end
html = "<tr class='row_#{row_index} #{css_class}' #{attributes}>"
@columns.each_with_index do |col, col_index|
cell = begin
row[col.name]
rescue
nil
end
html << col.render_cell(cell, row, row_index, col_index)
end
html << '</tr>'
end
# define a custom block to be used to determine the css class for a row.
def row_style(&b)
@row_style = b
end
def custom_header(&blk)
instance_eval(&blk)
end
def th(header_text, options)
@custom_headers << options.merge(text: header_text)
end
def row_attributes(&b)
@row_attributes = b
end
#############
# GROUPING
#############
# TODO: allow for group column only, block only and group column and block
def group_by(group_column, level = {level: 0}, &_blk)
if level.nil? && @groupings.count >= 1
raise 'a level designation is required when using multiple groupings.'
end
@grouped_data = true
@groupings[level ? level[:level] : 0] = group_column
@columns.reject! { |c| c.name == group_column }
end
def group_data!
@groupings.compact!
@collection = if @groupings.count > 1
collection.group_by_recursive(@groupings)
else
collection.group_by { |row| row[@groupings.first] }
end
end
def render_grouped_data_table_body(collection)
html = ''
collection.keys.each do |group_name|
html << render_group(group_name, collection[group_name])
end
html
end
def render_group_header(group_header, index = nil)
css_classes = ['group_header']
css_classes << ["level_#{index}"] unless index.nil?
html = "<tr class='#{css_classes.join(' ')}'>"
html << "<th colspan='#{@columns.size}'>#{group_header}</th>"
html << '</tr>'
repeat_headers(html) if @repeat_headers_for_groups
html
end
def repeat_headers(html)
html << "<tr class='col_headers'>"
@columns.each_with_index do |col, _i|
html << col.render_column_header
end
html << '</tr>'
end
def render_group(group_header, group_data)
html = "<tbody class='#{group_header.to_s.downcase.gsub(/[^A-Za-z0-9]+/, '_')}'>"
html << render_group_header(group_header, 0)
if group_data.is_a? Array
html << render_rows(group_data)
html << render_subtotals(group_header, group_data) if subtotals?
elsif group_data.is_a? Hash
html << render_group_recursive(group_data, 1, group_header)
end
html << '</tbody>'
end
def render_group_recursive(collection, index = 1, group_parent = nil, ancestors = nil)
html = ''
ancestors ||= []
collection.each_pair do |group_name, group_data|
ancestors << group_parent unless ancestors[0] == group_parent
ancestors << group_name unless ancestors.length == @groupings.length
if group_data.is_a?(Hash)
html << render_group_header(group_name, index)
html << render_group_recursive(group_data, index + 1, nil, ancestors)
elsif group_data.is_a?(Array)
html << render_group_header(group_name, index)
html << render_rows(group_data)
ancestors.pop
html << render_subtotals(group_name, group_data, ancestors) if subtotals?
end
end
html << render_parent_subtotals(ancestors) if @parent_subtotals
ancestors.pop
html
end
#############
# TOTALS AND SUBTOTALS
#############
def render_totals
html = '<tfoot>'
@total_calculations.each_with_index do |totals_row, index|
next if totals_row.nil?
html << "<tr class='total index_#{index}'>"
@columns.each do |col|
value = totals_row[col.name] ||= nil
html << col.render_cell(value)
end
html << '</tr>'
end
html << '</tfoot>'
end
def render_parent_subtotals(group_array)
html = ''
@parent_subtotals[group_array].each_with_index do |group, index|
next if group.nil?
html << "<tr class='parent_subtotal "
html << "index_#{index} #{group_array.join('_').gsub(/\s/, '_').downcase}'>"
@columns.each do |col|
value = group[col.name] ? group[col.name].values[0] : nil
html << col.render_cell(value)
end
html << '</tr>'
end
html
end
# ancestors should be an array
def render_subtotals(group_header, _group_data = nil, ancestors = nil)
html = ''
path = ancestors.nil? ? [] : ancestors.dup
path << group_header
is_first_subtotal = true
@subtotal_calculations[path].each_with_index do |group, index|
next if group.empty?
html << "<tr class='subtotal index_#{index} #{'first' if is_first_subtotal}'>"
@columns.each do |col|
value = group[col.name] ? group[col.name].values[0] : nil
html << col.render_cell(value)
end
html << '</tr>'
is_first_subtotal = false
end
html
end
def subtotal(column_name, function = nil, index = 0, &block)
raise 'You must supply an index value' if @subtotals.count >= 1 && index.nil?
total_row @subtotals, column_name, function, index, &block
end
def subtotals?
!@subtotals.empty?
end
def total(column_name, function = nil, index = 0, &block)
raise 'You must supply an index value' if @totals.count >= 1 && index.nil?
total_row @totals, column_name, function, index, &block
end
def totals?
!@totals.empty?
end
# TODO: Refactor to shorten method. Also revise tests.
def calculate_totals!
@total_calculations = []
@totals.each_with_index do |row, index|
next if row.nil?
if @collection.is_a?(Hash)
collection = []
@collection.each_pair_recursive { |_k, v| collection.concat(v) }
end
collection = @collection if @collection.is_a? Array
@total_calculations[index] = {} if @total_calculations[index].nil?
row.each do |item|
@total_calculations[index][item[0]] = calculate(collection, item[0], item[1])
end
end
end
def calculate_subtotals!
raise 'Subtotals only work with grouped results sets' unless @grouped_data
@subtotal_calculations ||= Hash.new { |h, k| h[k] = [] }
@subtotals.each_with_index do |subtotal_type, index|
subtotal_type.each do |subtotal|
@collection.each_pair_with_parents(@groupings.count) do |group_name, group_data, parents|
path = parents + [group_name]
result = calculate(group_data, subtotal[0], subtotal[1], path)
(0..index).each do |index|
@subtotal_calculations[path][index] ||= {}
end
@subtotal_calculations[path][index][subtotal[0]] = {subtotal[1] => result}
end
end
end
end
def calculate_parent_subtotals
@parent_subtotals = Hash.new { |h, k| h[k] = [] }
# Iterate over all the parent groups
parent_groups = @groupings.slice(0, @groupings.count - 1).compact
parent_groups.count.times do
# Group by each parent on the fly
@subtotals.each_with_index do |subtotal, index|
@collection.group_by_recursive(parent_groups).each_pair_with_parents do |group_name, data, parents|
subtotal.each do |s|
path = parents + [group_name]
result = calculate(data, s[0], s[1], path)
@parent_subtotals[path][index] ||= {} if @parent_subtotals[path][index].nil?
@parent_subtotals[path][index][s[0]] = {s[1] => result}
end
end
end
parent_groups.pop
end
end
# TODO: Write test for this
def calculate(data, column_name, function, path = nil)
column = @columns.select { |col| col.name == column_name }
if function.is_a?(Proc)
calculate_with_proc(function, data, column, path)
elsif function.is_a?(Array) && function[1].is_a?(Proc)
calculate_array_and_proc(function, data, column_name, path)
elsif function.is_a?(Array)
calculate_many(function, data, column_name, path)
else
send("calculate_#{function}", data, column_name)
end
end
def calculate_with_proc(function, data, column = nil, path = nil)
case function.arity
when 1 then function.call(data)
when 2 then function.call(data, column.first)
when 3 then function.call(data, column.first, path.last)
end
end
def calculate_array_and_proc(function, data, column = nil, path = nil)
result = send("calculate_#{function[0]}", data, column)
case function[1].arity
when 1 then function[1].call(result)
when 2 then function[1].call(result, column.first)
when 3 then function[1].call(result, column.first, path.last)
end
end
def calculate_many(function, data, column_name, _path = nil)
function.each do |func|
if func.is_a? Array
send("calculate_#{func[0]}", data, column_name)
else
send("calculate_#{func}", data, column_name)
end
end
end
def calculate_sum(collection, column_name)
collection.inject(0) { |sum, row| sum + row[column_name].to_f }
end
def calculate_avg(collection, column_name)
return 0 if collection.empty?
sum = calculate_sum(collection, column_name)
sum / collection.size
end
def calculate_max(collection, column_name)
collection.collect { |r| r[column_name].to_f }.max
end
def calculate_min(collection, column_name)
collection.collect { |r| r[column_name].to_f }.min
end
private
# Define a new total column definition.
# total columns take the name of the column that should be totaled
# they also take a default aggregate function name and/or a block
# if only a default function is given, then it is used to calculate the total
# if only a block is given then only it is used to calculated the total
# if both a block and a function are given then the default aggregate function is called first
# then its result is passed into the block for further processing.
def total_row(collection, column_name, function = nil, index = nil, &block)
function_or_block = function || block
f = function && block_given? ? [function, block] : function_or_block
(0..index).each do |index|
collection[index] = {} if collection[index].nil?
end
collection[index][column_name] = f
end
end
end