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 = "" html << "" 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 << "" end html << '
#{@title}
#{@empty_text}
' end def render_data_table_header html = '' html << render_custom_table_header unless @custom_headers.empty? html << '' @columns.each do |col| html << col.render_column_header end html << '' end def render_custom_table_header html = "" @custom_headers.each do |h| html << "#{h[:text]}" end html << '' end def render_data_table_body(collection) if @grouped_data render_grouped_data_table_body(collection) else "#{render_rows(collection)}" 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 = "" @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 << '' 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 = "" html << "#{group_header}" html << '' repeat_headers(html) if @repeat_headers_for_groups html end def repeat_headers(html) html << "" @columns.each_with_index do |col, _i| html << col.render_column_header end html << '' end def render_group(group_header, group_data) html = "" 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 << '' 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 = '' @total_calculations.each_with_index do |totals_row, index| next if totals_row.nil? html << "" @columns.each do |col| value = totals_row[col.name] ||= nil html << col.render_cell(value) end html << '' end html << '' end def render_parent_subtotals(group_array) html = '' @parent_subtotals[group_array].each_with_index do |group, index| next if group.nil? html << "" @columns.each do |col| value = group[col.name] ? group[col.name].values[0] : nil html << col.render_cell(value) end html << '' 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 << "" @columns.each do |col| value = group[col.name] ? group[col.name].values[0] : nil html << col.render_cell(value) end html << '' 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) 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