module ArToHtmlTable class TableFormatter include ArToHtmlTable::ColumnFormatter include ::ActionView::Helpers::NumberHelper attr_accessor :html, :table_columns, :klass, :merged_options, :rows, :totals attr_accessor :column_cache EXCLUDE_COLUMNS = [:id, :updated_at, :created_at, :updated_on, :created_on] CALCULATED_COLUMNS = /(percent|percentage|difference|diff)_of_(.*)/ DEFAULT_OPTIONS = { :exclude => EXCLUDE_COLUMNS, :exclude_ids => true, :odd_row => "odd", :even_row => "even", :totals => true, :total_one => 'tables.total_one', :total_many => 'tables.total_many', :unknown_key => 'tables.unknown', :not_set_key => 'tables.not_set' } # Initialize a table formatter. Not normally called directly since # Array#to_table takes care of this. # # results: the value to be formatted # options: formatter options # # ====Options # # :include Array of attributes to include in the table. Default is all attributes excepted :excluded ones. # :exclude Array of attributes to exclude from the table. Default is [:id, :updated_at, :created_at, :updated_on, :created_on] # :exclude_ids Exclude attributes with names ending in '_id'. Default is _true_ # :sort A proc invoked to sort the rows before output. Default is not to sort. # :heading Table heading places in the first row of a table # :caption Table caption applied with markup # :odd_row CSS Class name of the odd rows in the table. Default is _odd_ # :even_row CSS Class name of the even rows. Default is _even_ # :totals Include a total row if _true_. Default is _true_ # :total_one I18n key for displaying a table footer when there is one row. Default _tables.total_one_ # :total_many I18n key for displaying a table footer when there are > 1 rows. Default is _tables.total_many_ # :unknown_key I18n key for displaying _Unknown_. Default is _tables.unknown_ # :not_set_key I18n key for displaying _Not Set_. Default is _tables.no_set_ def initialize(results, options) raise ArgumentError, "[to_table] First argument must be an array of ActiveRecord rows" \ unless results.try(:first).try(:class).try(:descends_from_active_record?) || results.is_a?(ActiveRecord::NamedScope::Scope) raise ArgumentError, "[to_table] Sort option must be a Proc" \ if options[:sort] && !options[:sort].is_a?(Proc) @klass = results.first.class @rows = results @column_order = 0 @merged_options = DEFAULT_OPTIONS.merge(options) @table_columns = initialise_columns(rows, klass, merged_options) @totals = initialise_totalling(rows, table_columns) results.sort(options[:sort]) if options[:sort] @merged_options[:rows] = results @html = Builder::XmlMarkup.new(:indent => 2) @column_cache = {} end # Render the result set to an HTML table using the # options set at object instantiation. # # ====Examples # # products = Product.all # formatter = ArToHtmlTable::TableFormatter.new(products) # formatter.to_html def to_html options = merged_options table_options = {} html.table table_options do html.caption(options[:caption]) if options[:caption] output_table_headings(options) output_table_footers(options) html.tbody do rows.each_with_index do |row, index| output_row(row, index, options) end end end end protected # Outputs colgroups and column headings def output_table_headings(options) # Table heading html.colgroup do table_columns.each {|column| html.col :class => column[:name] } end # Column groups html.thead do html.tr(options[:heading], :colspan => columns.length) if options[:heading] html.tr do table_columns.each do |column| html_options = {} html_options[:class] = column[:class] if column[:class] html.th(column[:label], html_options) end end end end # Outputs one row def output_row(row, count, options) html_options = {} html_options[:class] = (count.even? ? options[:even_row] : options[:odd_row]) html_options[:id] = row_id(row) if row[klass.primary_key] html.tr html_options do table_columns.each {|column| output_cell(row, column, options) } end end # Outputs table footer def output_table_footers(options) output_table_totals(options) if options[:totals] && rows.length > 1 end # Output totals row (calculations) def output_table_totals(options) return unless table_has_totals? html.tfoot do html.tr do first_column = true table_columns.each do |column| value = first_column ? first_column_total(options) : totals[column[:name].to_s] output_cell_value(:th, value, column, options) first_column = false end end end end # Outputs one cell def output_cell(row, column, options = {}) output_cell_value(:td, row[column[:name]], column, options) end # Outputs one cells value after invoking its formatter def output_cell_value(cell_type, value, column, options = {}) column_name = column[:name].to_sym column_cache[column_name] = {} unless column_cache.has_key?(column_name) if column_cache[column_name].has_key?(value) result = column_cache[column_name][value] else result = column[:formatter].call(value, options.reverse_merge({:cell_type => cell_type, :column => column})) result ||= '' column_cache[column_name][value] = result end html.__send__(cell_type, (column[:class] ? {:class => column[:class]} : {})) do html << result end end private # Craft a CSS id def row_id(row) "#{klass.name.underscore}_#{row[klass.primary_key]}" end def default_formatter(data, options) case data when Fixnum integer_with_delimiter(data, options) else data.to_s end end def table_has_totals? !totals.empty? end def initialise_columns(rows, model, options) options[:include] = options[:include].map(&:to_s) if options[:include] options[:exclude] = options[:exclude].map(&:to_s) if options[:exclude] add_calculated_columns_to_rows(rows, options) requested_columns = columns_from_row(rows.first) columns = requested_columns.inject([]) do |definitions, column| definitions << column_definition(column) if include_column?(column, options) definitions end columns.sort{|a, b| a[:order] <=> b[:order] } end # Return a hash of hashes # :sum => {:column_name_1 => value, :column_name_2 => value} def initialise_totalling(rows, columns) columns.inject({}) do |totals, column| case column[:total] when :sum totals[column[:name]] = rows.make_numeric(column[:name]).sum(column[:name]) when :mean, :average, :avg totals[column[:name]] = rows.make_numeric(column[:name]).mean(column[:name]) when :count totals[column[:name]] = rows.make_numeric(column[:name]).count(column[:name]) end totals end end def first_column_total(options) if rows.count > 1 I18n.t(options[:total_many], :count => rows.count) else I18n.t(options[:total_one], :count => rows.count) end end def column_definition(column) @column_order += 1 @default_formatter ||= procify(:default_formatter) css_class, formatter = get_column_formatter(column.to_s) column_order = klass.format_of(column)[:order] || @column_order totals = klass.format_of(column)[:total] return { :name => column, :label => klass.human_attribute_name(column), :formatter => formatter || @default_formatter, :class => css_class, :order => column_order, :total => totals } end def columns_from_row(row) row.attributes.inject([]) {|columns, (k, v)| columns << k.to_s } end def get_column_formatter(column) format = klass.format_of(column) case format when Symbol formatter = procify(format) when Proc formatter = format when Hash css_class = format[:class] if format[:class] formatter = format[:formatter] if format[:formatter] formatter = procify(formatter) if formatter && formatter.is_a?(Symbol) end return css_class, formatter end # A data formatter can be a symbol or a proc # If its a symbol then we 'procify' it so that # we have on calling interface in the output_cell method # - partially for clarity and partially for performance def procify(sym) proc { |val, options| send(sym, val, options) } end # Decide if the given column is to be displayed in the table def include_column?(column, options) return options[:include].include?(column) if options[:include] return false if options[:exclude] && options[:exclude].include?(column) return false if options[:exclude_ids] && column.match(/_id\Z/) true end def add_calculated_columns_to_rows(rows, options) options.each do |k, v| if match = k.to_s.match(CALCULATED_COLUMNS) raise ArgumentError, "[to_table] Total value must not be 0 for percentage_of" if match[1] =~ /percent/ && v.to_f == 0 rows.each do |row| row[k.to_s] = case match[1] when 'percent', 'percentage' row[match[2]].to_f / v.to_f * 100 when 'difference', 'diff' row[match[2]].to_f - v.to_f else raise ArgumentError, "[to_table] Invalid calculated column '#{match[1]}' for '#{match[2]}'" end end end end end end end